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())
首先需要明确:
- 上述三个方法都是Object 类的方法。
- 上述三个方法都必须在同步代码块中使用。
上述一段话很重要!!!它说明了使用上述三个函数的方法以及方法的作用。
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 }