java并发之线程同步(synchronized和锁机制)

Posted LuckyBao

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了java并发之线程同步(synchronized和锁机制)相关的知识,希望对你有一定的参考价值。

 

正文

多个执行线程共享一个资源的情景,是并发编程中最常见的情景之一。多个线程读或者写相同的数据等情况时可能会导致数据不一致。为了解决这些问题,引入了临界区概念。临界区是一个用以访问共享资源的代码块,这个代码块在同一时间内只允许一个线程执行。

Java提供了同步机制。当一个线程试图访问一个临界区时,它将使用一种同步机制来查看是不是已有其他线程进入临界区。如果没有其他线程进入临界区,它就可以进入临界区;如果已有线程进入了临界区,它就被同步机制挂起,直到进入的线程离开这个临界区。如果在等待进入临界区的线程不止一个,JVM会随机选择其中的一个,其余的将继续等待。
概念比较好理解,具体在java程序中是如何体现的呢?临界区对应的代码是怎么样的?

使用synchronized实现同步方法

每一个用synchronized关键字声明的方法都是临界区。在Java中,同一个对象的临界区,在同一时间只有一个允许被访问。
注意:用synchronized关键字声明的静态方法,同时只能被一个执行线程访问,但是其他线程可以访问这个对象的非静态方法。即:两个线程可以同时访问一个对象的两个不同的synchronized方法,其中一个是静态方法,一个是非静态方法。
知道了synchronized关键字的作用,再来看一下synchronized关键字的使用方式。
  • 在方法声明中加入synchronized关键字
  • 1 public synchronized void addAmount(double amount) {
    2 }
  • 在代码块中使用synchronized关键字,obj一般可以使用this关键字表示本类对象
  • 1 synchronized(obj){
    2 }
需要注意的是:前面已经提到,引入synchronized关键字是为了声明临界区,解决在多线程环境下共享变量的数据更改安全问题。那么,一般用到synchronized关键字的地方也就是 在对共享数据 访问或者修改的地方。下面举一个例子,例子场景是这样:公司定时会给账户打款,银行对账户进行扣款。那么款项对于银行和公司来说就是一个共享数据。那么synchronized关键字就应该在修改账户的地方使用。
声明一个Account类:
复制代码
 1 public class Account {
 2     private double balance;
 3     public double getBalance() {
 4         return balance;
 5     }
 6     public void setBalance(double balance) {
 7         this.balance = balance;
 8     }
 9     public synchronized void addAmount(double amount) {
10         double tmp=balance;
11         try {
12             Thread.sleep(10);
13         } catch (InterruptedException e) {
14             e.printStackTrace();
15         }
16         tmp+=amount;
17         balance=tmp;
18     }
19     public synchronized void subtractAmount(double amount) {
20         double tmp=balance;
21         try {
22             Thread.sleep(10);
23         } catch (InterruptedException e) {
24             e.printStackTrace();
25         }
26         tmp-=amount;
27         balance=tmp;
28     }
29 }
复制代码
Bank类扣款:
复制代码
 1 public class Bank implements Runnable {
 2     private Account account;
 3     public Bank(Account account) {
 4         this.account=account;
 5     }
 6     public void run() {
 7         for (int i=0; i<100; i++){
 8             account.subtractAmount(1000);
 9         }
10     }
11 }
复制代码
Company类打款:
复制代码
 1 public class Company implements Runnable {
 2     private Account account;
 3     public Company(Account account) {
 4         this.account=account;
 5     }
 6 
 7     public void run() {
 8         for (int i=0; i<100; i++){
 9             account.addAmount(1000);
10         }
11     }
12 }
复制代码
这里需要注意的就是:在Bank和Company的构造函数里面传递的参数是Account,就是一个共享数据。
Main函数:
复制代码
 1 public class Main {
 2     public static void main(String[] args) {
 3         Account    account=new Account();
 4         account.setBalance(1000);
 5         Company    company=new Company(account);
 6         Thread companyThread=new Thread(company);
 7         Bank bank=new Bank(account);
 8         Thread bankThread=new Thread(bank);
 9 
10         companyThread.start();
11         bankThread.start();
12         try {
13             companyThread.join();
14             bankThread.join();
15             System.out.printf("Account : Final Balance: %f\\n",account.getBalance());
16         } catch (InterruptedException e) {
17             e.printStackTrace();
18         }
19     }
20 }
复制代码
这个例子比较简单,但是可以说明问题。
补充:
1、synchronized关键字会降低应用程序的性能,因此只能在并发场景中修改共享数据的方法上使用它。
2、临界区的访问应该尽可能的短。方法的其余部分保持在synchronized代码块之外,以获取更好的性能

使用非依赖属性实现同步

非依赖属性:例如在一个类中有两个非依赖属性,Object obj1,Object obj2;他们被多个线程共享,那么同一时间只允许一个线程访问其中的一个属性变量,其他的某个线程访问另一个属性变量。
举例如下:两个看电影的房间和两个售票口,一个售票处卖出的一张票,只能用于其中的一个电影院。不能同时作用于两个电影房间。
Cinema类:
复制代码
 1 public class Cinema {
 2     private long vacanciesCinema1;
 3     private long vacanciesCinema2;
 4 
 5     private final Object controlCinema1, controlCinema2;
 6 
 7     public Cinema(){
 8         controlCinema1=new Object();
 9         controlCinema2=new Object();
10         vacanciesCinema1=20;
11         vacanciesCinema2=20;
12     }
13     
14     public boolean sellTickets1 (int number) {
15         synchronized (controlCinema1) {
16             if (number<vacanciesCinema1) {
17                 vacanciesCinema1-=number;
18                 return true;
19             } else {
20                 return false;
21             }
22         }
23     }
24     
25     public boolean sellTickets2 (int number){
26         synchronized (controlCinema2) {
27             if (number<vacanciesCinema2) {
28                 vacanciesCinema2-=number;
29                 return true;
30             } else {
31                 return false;
32             }
33         }
34     }
35     
36     public boolean returnTickets1 (int number) {
37         synchronized (controlCinema1) {
38             vacanciesCinema1+=number;
39             return true;
40         }
41     }
42     public boolean returnTickets2 (int number) {
43         synchronized (controlCinema2) {
44             vacanciesCinema2+=number;
45             return true;
46         }
47     }
48     public long getVacanciesCinema1() {
49         return vacanciesCinema1;
50     }
51     public long getVacanciesCinema2() {
52         return vacanciesCinema2;
53     }
54 }
复制代码
这样的话,vacanciescinema1和vacanciescinema2(剩余票数)是独立的,因为他们属于不同的对象。这种情况下,只允许一个同时有一个线程修改vacanciescinema1或者vacanciescinema2,但是允许有两个线程同时修改vacanciescinema1和vacanciescinema2。

在同步块中使用条件(wait(),notify(),notifyAll())

首先需要明确:
  1. 上述三个方法都是Object 类的方法。
  2. 上述三个方法都必须在同步代码块中使用。
当一个线程调用wait()方法时,JVM将这个线程置入休眠,并且释放控制这个同步代码块的对象,同时允许其他线程执行这个对象控制的其他同步代码块。为了唤醒这个线程,必须在这个对象控制的某个同步代码块中调用notify()或者notifyAll()方法。
上述一段话很重要!!!它说明了使用上述三个函数的方法以及方法的作用。
 
wait():将线程置入休眠状态,并且释放控制这个同步代码块的对象,释放了以后其他线程就可以执行这个对象控制的其他代码块。也就是可以进入了。这个和Thread.sleep(millions)方法不同,sleep()方法是睡眠指定时间后自动唤醒。
notify()/notifyAll():使用wait()方法休眠的线程需要在该对象控制的某个同步代码块中 调用notify或者notifyAll()方法去唤醒,才能进入就绪状态等待JVM的调用。否则一致处于休眠状态。
难点:线程休眠和唤醒的时机,就是说什么时候调用notify()或者notifyAll()方法???
拿生产者和消费者的例子来说:生产者往队列中塞数据,消费者从队列中取数据,所以这个队列是共享数据
数据存储类 EventStorage
塞数据方法和取数据方法:set()、get()
复制代码
 1 public synchronized void set(){
 2             while (storage.size()==maxSize){
 3                 try {
 4                     wait();
 5                 } catch (InterruptedException e) {
 6                     e.printStackTrace();
 7                 }
 8             }
 9             storage.add(new Date());
10             System.out.printf("Set: %d\\n", storage.size());
11             notify();
12     }    
13    public synchronized void get(){
14             while (storage.size()==0){
15                 try {
16                     wait();
17                 } catch (InterruptedException e) {
18                     e.printStackTrace();
19                 }
20             }
21             System.out.printf("Get: %d: %s\\n",storage.size(),((LinkedList<?>)storage).poll());
22             notify();
23     }
复制代码
 
分析上面这个简单的程序:
1、方法使用synchronized关键字声明同步代码块。所以这个函数里面可以使用同步条件。
2、首先判断队列是否已经满了,这里要使用while而不是if。为什么呢?while是一致查询是否已经满了,而if是判断一次就完事了。
3、如果满了,调用wait()方法释放该对象,那么其他方法(例如get())就可以使用这个对象了。get()方法进入后取出一个数据,然后唤醒上一个被休眠的线程。
4、虽然线程被唤醒了,但是由于get()方法线程占用对象锁,所以set()方法处于阻塞状态。直到get()方法取出所有的数据满足休眠条件以后,set()方法重新执行
5、重复以上步骤

使用锁实现同步

Java提供了同步代码块的另一种机制,它比synchronized关键字更强大也更加灵活。这种机制基于Lock接口及其实现类(例如:ReentrantLock)
它比synchronized关键字好的地方:
1、提供了更多的功能。tryLock()方法的实现,这个方法试图获取锁,如果锁已经被其他线程占用,它将返回false并继续往下执行代码。
2、Lock接口允许分离读和写操作,允许多个线程读和只有一个写线程。ReentrantReadWriteLock
3、具有更好的性能
一个锁的使用实例:
复制代码
 1 public class PrintQueue {
 2     private final Lock queueLock=new ReentrantLock();
 3 
 4     public void printJob(Object document){
 5         queueLock.lock();
 6         
 7         try {
 8             Long duration=(long)(Math.random()*10000);
 9             System.out.printf("%s: PrintQueue: Printing a Job during %d seconds\\n",Thread.currentThread().getName(),(duration/1000));
10             Thread.sleep(duration);
11         } catch (InterruptedException e) {
12             e.printStackTrace();
13         } finally {
14             queueLock.unlock();
15         }
16     }
17 }
复制代码
声明一把锁,其中ReentrantLock(可重入的互斥锁)是Lock接口的一个实现
1 private final Lock queueLock=new ReentrantLock();
然后在函数里面调用lock()方法声明同步代码块(临界区)
1 queueLock.lock();
最后在finally块中释放锁,重要!!!
1 queueLock.unlock();

使用读写锁实现同步数据访问

锁机制最大的改进之一就是ReadWriteLock接口和他的唯一实现类ReentrantReadWriteLock.这个类有两个锁,一个是读操作锁,一个是写操作锁。使用读操作锁时可以允许多个线程同时访问,使用写操作锁时只允许一个线程进行。在一个线程执行写操作时,其他线程不能够执行读操作。
 
在调用写操作锁时,使用一个线程。
写操作锁的用法:
复制代码
1 public void setPrices(double price1, double price2) {
2         lock.writeLock().lock();
3         this.price1=price1;
4         this.price2=price2;
5         lock.writeLock().unlock();
6     }
复制代码
读操作锁:
复制代码
 1   public double getPrice1() {
 2         lock.readLock().lock();
 3         double value=price1;
 4         lock.readLock().unlock();
 5         return value;
 6     }
 7     public double getPrice2() {
 8         lock.readLock().lock();
 9         double value=price2;
10         lock.readLock().unlock();
11         return value;
12     }
复制代码

修改锁的公平性

ReentrantLock和ReetrantReadWriteLock构造函数都含有一个布尔参数fair。默认fair为false,即非公平模式。
公平模式:当有很多线程在等待锁时,锁将选择一个等待时间最长的线程进入临界区。
非公平模式:当有很多线程在等待锁时,锁将随机选择一个等待区(就绪状态)的线程进入临界区。
这两种模式只适用于lock()和unlock()方。而Lock接口的tryLock()方法没有将线程置于休眠,fair属性并不影响这个方法。

在锁中使用多条件(Multri Condition)

锁条件可以和synchronized关键字声明的临界区的方法(wait(),notify(),notifyAll())做类比。锁条件通过Conditon接口声明。Condition提供了挂起线程和唤醒线程的机制。
使用方法:
复制代码
 1 private Condition lines;
 2     private Condition space;
 3      */
 4     public void insert(String line) {
 5         lock.lock();
 6         try {
 7             while (buffer.size() == maxSize) {
 8                 space.await();
 9             }
10             buffer.offer(line);
11             System.out.printf("%s: Inserted Line: %d\\n", Thread.currentThread()
12                     .getName(), buffer.size());
13             lines.signalAll();
14         } catch (InterruptedException e) {
15             e.printStackTrace();
16         } finally {
17             lock.unlock();
18         }
19     }
20 public String get() {
21         String line=null;
22         lock.lock();        
23         try {
24             while ((buffer.size() == 0) &&(hasPendingLines())) {
25                 lines.await();
26             }
27             
28             if (hasPendingLines()) {
29                 line = buffer.poll();
30                 System.out.printf("%s: Line Readed: %d\\n",Thread.currentThread().getName(),buffer.size());
31                 space.signalAll();
32             }
33         } catch (InterruptedException e) {
34             e.printStackTrace();
35         } finally {
36             lock.unlock();
37         }
38         return line;
39     }

以上是关于java并发之线程同步(synchronized和锁机制)的主要内容,如果未能解决你的问题,请参考以下文章

Java 多线程并发编程之 Synchronized 关键字

java多线程并发系列之 (synchronized)同步与加锁机制

Java并发编程之synchronized

Java开发之高并发必备篇——线程安全操作之synchronized

Java开发之高并发必备篇——线程安全操作之synchronized

JAVA多线程之Synchronize 关键字原理