《Java核心技术卷一》笔记 多线程同步(底层实现)
一、锁的基本原理
多个线程同时对共享的同一数据存取 ,在这种竞争条件下如果不进行同步很可能会造成数据的讹误。
例如:有一个共享变量int sum=0, 一个线程正调用 sum+=10,另一个线程正好也在调用sum+=20,期望的结果应该是sum=30。 但是+=操作并不是原子的,虚拟机需要用多条指令才能来完成这个操作(load,add, store),每个指令执行完都有可能被剥夺执行权,同时让另一个线程继续运行。(可以使用javap -c -v CLASS命令将class文件反编译为可阅读的虚拟机字节码,能够看到虚拟机实际执行的指令)。
{
register=sum; //load
register=sum+N; //add
sum=register; //store
}
不使用锁进行同步
很有可能出现类似于下面的情况,实际的结果将是错误的。
线程1 | 线程1寄存器 | 线程2 | 线程2寄存器 | 变量sum |
load指令:将sum变量值存入线程1的寄存器 | 0 | x | 0 | |
add指令:寄存器中值+10 | 10 | x | 0 | |
线程1中断,线程2开始执行 | 10 | x | 0 | |
10 | load指令:将sum变量值存入线程2的寄存器 | 0 | 0 | |
10 | add指令:寄存器中值+20 | 20 | 0 | |
10 | store指令:将寄存器中值写回sum变量 | 20 | 20 | |
10 | 线程2执行完成,线程1继续执行 | 20 | 20 | |
store指令:将寄存器值写回sum变量 | 10 | 20 | 10 | |
线程1执行完成 | 10 | 20 | 10 |
示例代码:(为了演示,把+=操作拆成了3条语句,并且调用Thread.sleep(1)模拟执行3条语句中间的中断 )
class MyTask implements Runnable { private int add=0; private int[] suma; public MyTask(int[] suma,int add) { this.add=add; this.suma=suma; } @Override public void run() { try { int register=suma[0]; register+=add; Thread.sleep(1); suma[0]=register; } catch (Exception e) { e.printStackTrace(); } } }; int[] sum=new int[1]; sum[0]=0; Thread t1=new Thread(new MyTask(sum,10)); Thread t2=new Thread(new MyTask(sum,20)); t1.start(); t2.start(); while(t1.isAlive() || t2.isAlive()) Thread.sleep(100); System.out.println("expect:"+30+" real:"+sum[0]);
出现错误的原因就是线程存取共享数据的过程中,另一个线程也可以存取共享数据,数据自然就紊乱了。解决的思路也很简单:存取共享数据的代码,一次只允许一个线程执行,互补干扰,就永远不会出现错误的情况了。
Java中的锁机制就是这个思路的具体实现,锁可以保证线程一个一个互斥的访问临界区。一个线程获得了锁才能进入临界区,同时其他线程都不再能获得锁而被锁挡在临界区外,只有当获得锁的线程离开临界区代码并且解开了锁,剩余的线程里才会再有一个幸运儿再次获得锁进入临界区,锁继续挡住其他的线程。
使用锁进行同步
首先创建一个共享的锁对象(Lock,ReentrantLock),线程1执行存取共享变量的代码之前(进入临界区)先调用锁对象的lock方法进行锁定,然后才开始执行load,add指令。add指令执行完线程1被中断了,线程2开始执行,同样的也要先调用lock方法,但是该锁对象已经处于锁定的状态了,线程2调用lock方法会一直被阻塞,直到持有这个锁的线程(线程1)把锁解开后线程2的lock调用才能成功返回。也就是说只要线程1不解锁,线程2就别想再走一步了。线程2因为要等线程1释放锁所以阻塞了,线程1又得到执行的机会,继续执行store指令,然后线程1访问共享变量的部分就执行完了,它还必须要调用锁对象的unlock方法解锁,让其他线程能够再次获得锁。线程2现在还被lock方法阻塞着,忽然等待的事件发生了(线程1解锁),线程2才能又有机会继续执行,这时lock方法终于成功返回了,锁被线程2持有了(如果还有其他线程调用lock方法来想要获得这个锁,更刚才线程2的情况一样,会被阻塞晾到一边等着)。线程2依次执行load,add,store指令完成功能,然后解锁。整个过程就结束了。
线程1 | 线程1寄存器 | 线程2 | 线程2寄存器 | 变量sum |
线程1调用lock() | 0 | x | 0 | |
load指令:将sum变量值存入线程1的寄存器 | 0 | x | 0 | |
add指令:寄存器值+10 | 10 | x | 0 | |
线程1中断,线程2开始执行 | 10 | 0 | 0 | |
10 | 线程2调用lock()阻塞.... | 0 | 0 | |
10 | 线程2中断,线程1继续执行 | 0 | 0 | |
store指令:将寄存器中值写回sum变量 | 10 | 0 | 10 | |
线程1执行完成,调用unlock() | x | 0 | 10 | |
x | 线程2继续执行,阻塞的lock()方法终于成功返回 | 0 | 10 | |
x | load指令:将sum变量值存入线程2的寄存器 | 10 | 10 | |
x | add指令:寄存器值+20 | 30 | 10 | |
x | store指令:将寄存器中值写回sum变量 | 30 | 30 | |
x | 线程2执行完成,线程1继续执行 | 30 | 30 |
示例代码:(线程数扩大为100个)
class MyTask implements Runnable { private int add=0; private int[] suma; private Lock lock; public MyTask(int[] suma,int add, Lock lock) { this.add=add; this.suma=suma; this.lock=lock; } @Override public void run() { try { lock.lock(); int register=suma[0]; register+=add; Thread.sleep(1); suma[0]=register; } catch (Exception e) { e.printStackTrace(); } finally { lock.unlock(); } } }; int[] sum=new int[1]; sum[0]=0; int expect=0; ReentrantLock lock=new ReentrantLock(); Thread[] ts=new Thread[100]; for(int i=0; i<100; i++) { int v=i+1; ts[i]=new Thread(new MyTask(sum,v,lock)); ts[i].start(); expect+=v; } for(int i=0; i<100; i++) if(ts[i].isAlive()) Thread.sleep(100); System.out.println("expect:"+expect+" real:"+sum[0]);
死锁
所有线程都在阻塞等待特定的条件,没有一个能继续执行的情况就是死锁。
在代码层面依靠Java中各种同步机制都不能避免和打破死锁。应该在设计的层面排除死锁产生的条件。
二、Java中实现同步
1.锁对象
可重入锁。一个线程如果已经获得了锁,还可以再次获得该锁。被一个锁保护的代码里可以调用另一个被该锁包含的方法。锁中有一个持有计数用来追踪lock方法的嵌套调用,有多少次lock调用就要有多少次unlock调用才能释放锁。
只用共享使用同一个锁对象的线程才会被该锁同步。如果不同线程用的是不同的锁对象,线程之间不会有影响。
- ReentrantLock 可重入锁
- new ReentrantLock()
- new ReentrantLock(boolean fair) 是否构建成公平锁。公平锁偏爱等待时间最长的线程,但是性能会大大降低,也不能完全保证线程调度器是公平的。
- void lock() 获取这个锁,如果该锁已经被别的线程拥有则阻塞,无法被中断。获取和释放锁不能使用带资源的try语句(try-resouces:首部需要创建新的变量,释放调用的是AutoClose接口的close方法)
- void lockInterruptibly() 阻塞,可以被中断。等效于tryLock(无限时长超时)
- void unlock() 释放该锁。最好要放到finally块中保证该方法一定被调用到。
- boolean tryLock() 立刻尝试获取锁,成功返回true,失败返回false。无视公平机制,无视正在排队的其他线程。
- boolean tryLock(long timeout, TimeUnit unit) 立即尝试获取锁。遵守公平机制,可以被中断(等待期间遇到中断抛出InterruptedException)
- Condition newCondition()
线程结束时,持有的所有锁都会被释放。
2.条件对象
通常线程进入临界区后,却发现还要等某一条件满足了才能执行(因为该线程一直等到条件满足,而其他线程被锁挡在无法修改条件,就出现死锁了)。条件对象就是用来管理这些已经获得了锁但确不能做有用工作的线程。
一个锁可以由一个或多个相关的条件对象。
- Condition 条件对象
- void await() 当前线程加入该条件的等待集,放弃锁并阻塞。即使锁可用,该线程也不能解除阻塞,直到另一个线程在同一个条件上调用signalAll()方法。该线程无法再激活自身,只能寄希望与其他线程的signalAll()方法。可中断。
- void awiatUninterruptibly() 不可中断,不推荐使用
- void awiat(int timeout, TimeUnit unit) 阻塞,超时时间到或被singalAll/singal方法激活或被中断时返回。可中断
- void signalAll() 移除该条件等待集中的所有线程。这些线程可以再次被调度器选中运行,锁可用时,其中的某个线程将从await方法返回,获得锁并继续执行。此时条件并不一定被满足了,还需要再次测试。这个方法至关重要一定要能调用到(在对象状态向有利于等待线程的方向改变后调用),否则所有线程await后就死锁了。
- void signal() 随机解除等待集中的某个线程的阻塞状态。这个方法存在风险:有可能被解除的线程发现还是不能运行,而且没有其他线程调用singal了,死锁。
下面是一个使用了锁和条件的示例,模拟银行类:
public class Bank { private Lock bankLock; private Condition condition; private double[] accounts; public Bank(int n, double initBalance) { bankLock=new ReentrantLock(); condition=bankLock.newCondition(); accounts=new double[n]; for(int i=0; i<n; i++) accounts[i]=initBalance; } public int getSize() { return accounts.length; } public void transfer(int from, int to, double amount) throws InterruptedException { bankLock.lock(); try { while(accounts[from]<amount) condition.await(); accounts[from]-=amount; accounts[to]+=amount; System.out.println(from+"=="+amount+"==>"+to+", \t total:"+getTotalBalance()); condition.signalAll(); } finally { bankLock.unlock(); } } public double getTotalBalance() { bankLock.lock(); try { double sum=0; for(double a: accounts) sum+=a; return sum; } finally { bankLock.unlock(); } } }
3.对象内置锁和条件,synchronized关键字
前两个小节的锁和条件时java中最基本的同步机制,开发者可以以此为基础设计复杂的锁控制机制。此外,Java还提供了一些比较通用的便捷方式。在每个Java对象中, 都隐含了一个内部锁和该锁的一个条件对象。该对象的方法可以使用synchronized关键字声明,那么一个线程要调用该方法时会自动调用该对象内部锁的lock,对象方法调用完成后会自动unlock。方法中调用this.wait()方法和this.notifyAll()/this.notify()方法实际上使用了内部的条件对象的对应方法,(这三个方法都是Object类的final方法,与条件对象的方法名不同以避免冲突)。静态方法上加synchronized关键字,使用的是类对象(XXXX.class)的内部锁和条件对象。
内部锁和条件有一些限制:不能中断正在试图获取锁的线程。试图获取锁时不能设置超时。每个所只有一个条件,可能不够用。
同步实现方式推荐:
- 首先应该使用阻塞队列等其他机制来实现
- 其次才考虑synchronized关键字
- 除非要用Lock/Condition独有的特性时才使用Lock/Contidition
这几个方法实际上都是调用了对象内部隐含Condition对象的对应方法,只能在synchronized方法或块内调用。如果当前线程不是对象锁的持有线程则抛出IllegalMonitorStateException异常。
Object
-
- void wait()
- void wait(long ms)
- void wait(long ms, int ns)
- void notifyAll()
- void notify
使用对象内置锁改写前面的Bank类(使用隐含在对象内部的锁和条件,synchronzied关键字自动实现内置锁的lock,unlock调用)
class Bank1 { private double[] accounts; public Bank1(int n, double initBalance) { accounts=new double[n]; for(int i=0; i<n; i++) accounts[i]=initBalance; } public int getSize() { return accounts.length; } public synchronized void transfer(int from, int to, double amount) throws InterruptedException { while(accounts[from]<amount) this.wait(); accounts[from]-=amount; accounts[to]+=amount; System.out.println(from+"=="+amount+"==>"+to+", \t total:"+getTotalBalance()); this.notifyAll(); } public synchronized double getTotalBalance() { double sum=0; for(double a: accounts) sum+=a; return sum; } }
同步块和客户端锁定
既然每个对象内部都有一个隐含的锁和条件。也可以用一个其他对象代替Lock和Condition的对象,这也是synchronized关键字的另一种用法,这种方式也叫客户端锁定(对象的内部锁可以被外部客户使用)。
如果一个对象仅仅作为锁的替代被使用,是不会有什么问题。但是如果希望使用该锁的方法和用做锁的对象自身的同步方法进行协同就需要注意了(例如用一个Verctor对象作为锁,希望用锁的这个方法和vector对象自身的add,remove等方法进行同步,不能同时执行),因为不知道对象内部那些方法是同步的,所有这样使用锁会很脆弱。所有也不推荐使用。
示例如下:
class Bank2 { private Vector<Double> accounts; public Bank2(int n, double initBalance) { accounts=new Vector<Double>(); for(int i=0; i<n; i++) accounts.add(initBalance); } public int getSize() { return accounts.size(); } public void transfer(int from, int to, double amount) throws InterruptedException { synchronized(accounts) { while(accounts.get(from)<amount) accounts.wait(); accounts.set(from, accounts.get(from)-amount); accounts.set(to, accounts.get(to)+amount); System.out.println(from+"=="+amount+"==>"+to+", \t total:"+getTotalBalance()); accounts.notifyAll(); } } public double getTotalBalance() { synchronized(accounts) { double sum=0; for(double a: accounts) sum+=a; return sum; } } }
4.监视器设计模式
Lock和Condition功能虽强,但不是面向对象的。监视器不是具体的类,而是一种保证多线程安全的类设计方式,满足其条件的类就可以认为是监视器。
特点:
- 只包含私有域
- 每个对象有一个锁
- 对象的锁可以由多个条件
- 所有方法都加锁同步,调用前获取锁,调用后释放锁
Java部分实现了监视器,每个对象都有内部的锁和条件,方法可以用synchnorized关键字同步。但是也有区别:并不要求所有域私有;方法也不要求都同步;内部锁对客户可用。
5.volatile域
不同的线程如果仅仅是读写内存中共享的实例域,按道理说应该是不会出错的。但因为两个原因,实际会出现很大的问题。一是线程会把内存中的值复制到寄存器或是缓冲区中,代码中的同一个变量在不同的线程中实际已经不在一个地方了。二是编译器会进行优化,改变指令顺序增加吞吐量,优化后读取的值很可能存在差异。
使用锁进行同步可以保证正确性。锁会要求在必要的时候刷新本地缓存来保持锁的效应,并且不能不正当的重新排序指令。
仅仅为读写一两个实例域就使用同步,开销较大。而且有的时候使用锁也比较麻烦。例如某个实例的getter和setter方法加了synchnorized关键字,如果一个线程获得了该对象锁并调用了一个耗时较长的方法,那么另一个线程调用get,set方法都会阻塞。解决方法是为这个实例单独使用一个锁,但是就更麻烦了。
volatile关键字为实例域的同步读取提供了一种免锁机制。编译器和虚拟机知道该域可能被另一个线程并发更新。但是volatile并不能保证域的原子性修改,仅适用于除了赋值之外并不完成其他操作的域。
6.final变量
final变量赋值后不能再修改,所有线程看到的都是同一个值,读取是安全的。如果变量是一个对象,调用对象的方法就不一定安全了,需要考虑同步。
7.原子性
java.util.concurrent.atomic包中提供了很多类使用高效机器级指令来而不是锁来保证操纵的原子性。例如AtomicInteger提供了incrementAndGet,decrementAndGet方法实现原子性的自增自减操作,用作共享计数器时无需同步。其他类有AtomicBoolean, AtomicLong, AtomicReference以及Boolean, int, long, reference的原子数组。这些类主要用于开发并发工具,不推荐使用。
8.ThreadLocal线程局部变量
有时候需要避免共享变量,例如共享SimpleDataFormat对象,如果不同步,多个线程同时使用这个对象时会造成内部数据紊乱;如果使用同步,开销很大;如果每个线程中创建局部的对象,也很浪费。这种情况可以考虑使用TreadLocal。
ThreadLocal类对象被多个线程共享,每个线程中第一次调用get时会调用initialValue方法为该线程初始化一个新实例,以后再调用get方法都会返回这个实例(ThreadLocal类内部维持着一个以Thread为key的映射)。
public static final ThreadLocal<SimpleDateFormat> dateformat=new ThreadLocal<SimpleDateFormat>() { protected SimpleDateFormat initialValue() { return new SimpleDateFormat("yyyy-MM-dd"); } }; //Runnable对象的run方法中 String dateStamp=dateFormat.get().format(new Date());
ThreadLocal<T>
-
- T get() 返回对应当前线程的变量值
- protected T initialValue() 为当前线程创建新实例,默认为null
- void set(T t) 设置新值
- void T remove() 删除值
生成随机数也有类似的问题,Random类是线程安全的,但是通过同步共享效率很低,也可以使用ThreadLocal的方式。或者直接使用ThreadLocalRandom类。
//Runnable对象的run方法中 int rand=ThreadLocalRandom.current().nextInt(10);
ThreadLocalRandom
-
- ThreadLocalRandom current() 返回对应当前线程的随机数生成器对象
9.读写锁
有些数据可以同时被多个线程读取,但是同时只能有一个线程修改。使用ReentrantReadWriteLock类跟适合, ReentrantReadWriteLock类对象可以抽取出两个锁,一个是同步读取线程的读锁,一个是用于同步修改线程的写锁。
ReentrantReadWriteLock 没有实现Lock接口,而是实现了ReadWriteLock接口,不能赋值给Lock变量
-
- Lock readLock() 得到一个可以被多个读操作共用的读锁,但会排斥所有写操作。
- Lock writeLock() 得到一个写锁,排斥所有读操作和其他写操作
使用读写锁的示例:
class Bank3 { private ReadWriteLock bankLock; private Lock readLock; private Lock writeLock; private Condition condition; private double[] accounts; public Bank3(int n, double initBalance) { bankLock=new ReentrantReadWriteLock(); readLock=bankLock.readLock(); writeLock=bankLock.writeLock(); condition=writeLock.newCondition(); accounts=new double[n]; for(int i=0; i<n; i++) accounts[i]=initBalance; } public int getSize() { return accounts.length; } public void transfer(int from, int to, double amount) throws InterruptedException { writeLock.lock(); try { while(accounts[from]<amount) condition.await(); accounts[from]-=amount; accounts[to]+=amount; System.out.println(from+"=="+amount+"==>"+to+", \t total:"+getTotalBalance()); condition.signalAll(); } finally { writeLock.unlock(); } } public double getTotalBalance() { readLock.lock(); try { double sum=0; for(double a: accounts) sum+=a; return sum; } finally { readLock.unlock(); } } }
10.相对安全的挂起实现
直接调用Thread类的suspend()和resume()方法挂起的位置是是不确定的,很可能造成死锁,风险太大。但是有时确实需要挂起的功能,用锁和条件对象可以实现相对安全的申请挂起操作,可以保证在固定的安全位置才挂起,但是代码结构设计不当仍然可能死锁。
挂起造成死锁的示例(子线程有可能在获得list对象锁后在临界区中间被挂起,主线程调用getCount()获取list对象锁失败被阻塞,形成死锁):
public void testSuspendDeadthLock() throws InterruptedException { class UnSafeSuspendTask implements Runnable { private List<Integer> list=new ArrayList<Integer>(); @Override public void run() { for(int i=0; i<1000; i++) { synchronized(list) { list.add(i); System.out.println("input "+i); } try { Thread.sleep(1); } catch (InterruptedException e) { return; } } } public int getCount() { synchronized(list){ return list.size(); } } } UnSafeSuspendTask task=new UnSafeSuspendTask(); Thread t=new Thread(task); t.start(); while(t.isAlive()) { t.suspend(); System.out.println("count: "+task.getCount()); t.resume(); Thread.sleep(1); } System.out.println("--done--"); }
安全的申请挂起方式,保证在安全的位置才挂起:
public void testSuspendRequest() throws InterruptedException { class SafeSuspendTask implements Runnable { private volatile boolean suspendRequested=false; private Lock suspendLock=new ReentrantLock(); private Condition suspendCondition=suspendLock.newCondition(); private List<Integer> list=new ArrayList<Integer>(); @Override public void run() { for(int i=0; i<1000; i++) { //do work... synchronized(list) { list.add(i); System.out.println("input "+i); } try { Thread.sleep(1); } catch (InterruptedException e) { return; } //suspend check if(suspendRequested) { suspendLock.lock(); try{ while(suspendRequested) suspendCondition.await(); } catch(InterruptedException e) { return; } finally{ suspendLock.unlock(); } } } } public int getCount() { synchronized(list){ return list.size(); } } public void requestSuspend() { suspendRequested=true; } public void requestResume() { suspendRequested=false; suspendLock.lock(); try{ suspendCondition.signalAll(); } finally{ suspendLock.unlock(); } } } SafeSuspendTask task=new SafeSuspendTask(); Thread t=new Thread(task); t.start(); while(t.isAlive()) { task.requestSuspend(); System.out.println("count: "+task.getCount()); task.requestResume(); Thread.sleep(1); } System.out.println("--done--"); }