前言
线程间通信的实现,主要通过共享对字段的访问和引用字段所引用的对象。线程之间的通信机制有两种,共享内存和消息传递。
目录
一、概念
线程共享
通过共享内存对字段的访问和引用字段所引用的对象。这种通信形式非常有效,但可能出现两种错误:thread interference (线程干扰) 和 memory consistency errors (内存一致性错误)。防止这些错误所需的工具是 synchronization (同步)。
同步
同步是围绕称为 intrinsic lock (内部锁) 或 monitor lock (监视器锁) 的内部实体构建的。(API 规范通常将此实体简称为“监视器”。)内部锁在同步的两个方面都发挥作用:强制对对象状态进行独占访问,并建立对可见性至关重要的 happens-before 关系。
每个对象都有一个与之相关联的内部锁。按照规范,需要对对象字段进行独占和一致访问的线程必须在访问对象之前 acquire (获取) 对象的内部锁,然后在完成时 release (释放) 内部锁。一个线程在获取锁和释放锁之间的时间段内,被称为own (拥有) 内部锁。只要一个线程拥有一个内部锁,其他线程就不可以获取相同的锁。另一个线程在尝试获取锁时将阻塞。当线程释放内部锁时,在该操作与同一锁的任何后续获取之间建立 happens-before 关系。
二、synchronized
Java 编程语言提供两种基本的同步习惯用法:synchronized methods (同步方法) 和 synchronized statements (同步语句)。所有加上synchronized修饰的方法和块语句,在多线程访问的时候,同一时刻只能有一个线程能够执行synchronized修饰的方法或者代码块。
同步方法
要使方法同步,只需将 synchronized 关键字添加到其声明中:
1 | public class SynchronizedCounter { |
使用同步方法有两个影响:
- 首先,对同一对象的两个同步方法的调用不可能进行交错。当一个线程在执行一个对象的同步方法的时候,其他所有的调用该对象的同步方法的线程都会被挂起(暂停执行),直到第一个线程对该对象的操作完毕。
- 其次,当同步方法退出时,它会自动与同一对象的同步方法的 any subsequent invocation (任何后续调用) 建立 happens-before 关系。这就确保了对该对象的修改对其他线程是可见的。
注:构造函数不能同步,将
synchronized关键字与构造函数一起使用是一种语法错误。同步构造函数没有意义,因为只有创建对象的线程在构造时才能访问它。
当线程调用同步方法时,它会自动获取该方法的对象的内部锁,并在方法返回时释放它。即使返回是由未捕获的异常引起的,也会释放锁。
同步语句
与同步方法不同,同步语句必须指定提供内部锁的对象:
1 | public class SynchronizedCounter { |
类锁
synchronized修饰类或静态方法时实质锁的是class对象。类锁的作用范围是类级别的,不会因为同一个类的不同对象执行而失效。
synchronized修饰静态方法与synchronized(xxx.class)互斥,因为静态方法与类关联,而不是与对象关联。在这种情况下,线程获取与该类关联的 Class 对象的内部锁。因此,访问类的静态字段是由一个锁控制的,该锁与任何该类的实例的锁都不同。
1 | class ClassLock{ |
对象锁
synchronized修饰实例对象或非静态方法时实质锁的是实例对象。对象锁的作用范围是对象级别的,即仅仅作用于同一个对象时锁才生效,如果是同一个类的两个不同的对象是不会互斥的,即没有效果的。
synchronized修饰非静态方法与synchronized(this)互斥,synchronized修饰非静态方法实质锁的是当前对象(this)。
1 | class ObjectLock{ |
注:类锁和对象锁是相互独立的,互不相斥。两者作用范围不同。
错误的加锁
1 | public class Worker implements Runnable{ |
分析在执行i++会调用Integer的valueOf()方法,会重新new一个Integer对象,因此对象i就发生了变化,锁的对象就不一样了,就不是同一个锁,synchronized也就无法锁住了。
1 | public static Integer valueOf(int i) { |
注:synchronized锁的都是锁某个具体对象,要让线程安全同步运行,保证锁的对象不会发生变化。
特性
synchronized锁并不能被中断,可重入锁,不带超时功能,悲观锁,非公平锁。
等待/唤醒需要使用synchronized锁。当执行了对象的
wait()方法后会释放该锁,执行notify()/notifyAll()方法后不会释放锁。使用synchronized锁时尽量缩小锁的范围以保证性能,推荐尽量使用synchronized代码块来降低锁的范围,避免使用synchronized来修饰方法。尽量找到并发访问代码的临界区。
实现原理
在JVM里的实现是基于进入和退出Monitor对象来实现方法同步和代码块同步,使用monitorenter和monitorexit指令实现的:
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处。
每个monitorenter必须有对应的monitorexit与之配对。
任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。
从反编译的结果来看,对同步块,MonitorEnter指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁,而monitorExit指令则插入在方法结束处和异常处。对同步方法,并没有通过指令monitorenter和monitorexit来实现,相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。
synchronized使用的锁是存放在Java对象头里面,具体位置是对象头里面的MarkWord,MarkWord里默认数据是存储对象的HashCode等信息,但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式。

了解各种锁
自旋锁
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
但是线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,线程不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。
如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
优缺点:
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗!
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cup的线程又不能获取到cpu,造成cpu的浪费。
自旋锁时间阈值:
自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋次数很重要。
JVM对于自旋次数的选择,jdk1.5默认为10次,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。JDK1.6中-XX:+UseSpinning开启自旋锁; JDK1.7后,去掉此参数,由jvm控制。
锁的状态
一共有四种状态,级别从低到高依次是:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态。它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。
偏向锁:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。无竞争时不需要进行CAS操作来加锁和解锁。它会偏向于第一个访问锁的线程。
轻量级锁:是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁; 无竞争时通过CAS操作来加锁和解锁。(自旋锁——是一种锁的机制,不是状态)
重量级锁:由轻量级锁自旋加锁失败多次后升级为重量级锁,真正的加锁操作,阻塞竞争线程。

注:synchronized的实现的优化是引入了偏向锁、轻量级锁。JDK引入了自旋锁和适应性自旋锁。
三、ThreadLocal
ThreadLocal 是线程局部(本地)变量,提供了线程内存储变量的能力,这些变量不同之处在于每一个线程读取的变量是对应的互相独立的,通过 get 和 set 方法就可以得到当前线程对应的值。
原理
Thread 类有一个 ThreadLocal.ThreadLocalMap 类型的实例变量 threadLocals,ThreadLocal 的静态内部类 ThreadLocalMap 为每个 Thread 都维护了一个数组 table,ThreadLocal 对象确定了一个数组下标,而这个下标就是 value 存储的对应位置。具体原理见《ThreadLocal源码分析》。
区别
ThreadLocal和Synchonized都用于解决多线程并发访问。可是ThreadLocal与synchronized有本质的差别。synchronized是利用锁的机制,使变量或代码块在某一时该仅仅能被一个线程访问。而ThreadLocal为每个线程都提供了变量的副本,使得每个线程在某一时间访问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。
注:使用ThreadLocal变量的值对各线程是不需要同步的,每个线程使用的是变量的副本。而使用synchronized可以使变量值做到同步。
四、volatile
最轻量的同步机制
Java语言提供了弱同步机制,即volatile变量,以确保变量的更新通知其他线程。volatile变量具备变量可见性、禁止重排序两种特性。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
特性
volatile变量的两种特性:
变量可见性
保证该变量对所有线程可见,这里的可见性指的是当一个线程修改了变量的值,那么新的值对于其他线程是可以立即获取的。强制线程修改完把变量值立即同步到主内存,其它线程从主内存中读取变量值。
禁止重排序
volatile禁止了指令重排。比sychronized更轻量级的同步锁。在访问volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。volatile适合这种场景:一个变量被多个线程共享,线程直接给这个变量赋值。
原子性
对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。volatile虽然能保证执行完及时把变量刷到主内存中,但对于count++这种非原子性、多指令的情况,由于线程切换,线程A刚把count=0加载到工作内存,线程B就可以开始工作了,这样就会导致线程A和B执行完的结果都是1,都写到主内存中,主内存的值还是1不是2。
适用场景
值得说明的是对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。在某些场景下可以代替synchronized。但是,volatile的不能完全取代synchronized的位置,只有在一些特殊的场景下(如一写多读),才能适用volatile。无法保证一个变量在多线程同时写的时候线程安全。并发编程中,使用volatile + CAS操作来实现原子操作,代替synchronized。
总体来说,需要必须同时满足下面两个条件时才能保证并发环境的线程安全:
(1)对变量的写操作不依赖于当前值,或者说是写操作独立互不关联,变量赋值(boolean flag = true)。
(2)该变量没有包含在具有其他变量的不变式中,也就是说,不同的volatile变量之间,不能互相依赖。只有在状态真正独立于程序内其他内容时才能使用volatile。
注:volatile保证可见性和单个读/写操作(get/set)原子性,不能保证复合操作(volatile++)原子性。synchronized能保证可见性和操作原子性。
深入理解
如果要深入了解volatile关键字的作用,就必须先来了解一下JVM在运行时候的内存分配过程。其中有一个内存区域是jvm虚拟机栈,每一个线程运行时都有一个线程栈,线程栈保存了线程运行时候变量值信息。当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值,在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了。如下图:

因此volatile关键字作用就是强制线程每次读取变量值的时候都去“主内存”中取值,并同步修改后的值到主内存。
实现原理
通过对OpenJDK中的unsafe.cpp源码的分析,会发现被volatile关键字修饰的变量会存在一个“lock:”的前缀。Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。
有volatile变量修饰的共享变量进行写操作的时候会使用CPU提供的Lock前缀指令:
- 将当前处理器缓存里的volatile所在行的数据(volatile变量值)写回到系统内存。
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
五、线程间协作
利用等待和通知机制完成线程间协作。等待wait()和通知notify()/notifyAll()需要synchronized锁。
标准使用范式
1 | //等待 |
wait
wait()方法执行后会释放锁,wait()之后的代码不会执行。被唤醒后,线程要重新竞争锁,拿到锁之后才会执行wait()之后的代码。
notify/notifyAll
notify()/notifyAll()方法执行后不会释放锁,等同步代码块执行完才释放,
注:notify()方法只唤醒该对象上的一个等待线程,notifyAll()方法唤醒该对象上所有等待线程。
wait()、notify()、notifyAll()方法是Object类的方法。
六、死锁
概念
是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
发生条件
1.多操作者(M>=2个)情况下,争夺多个资源(N>=2个,且N<=M)。
2.争夺资源的顺序不对。
3.争夺者拿到资源不放手。
学术化的定义四个必要条件:互斥条件、请求和保持条件、不剥夺条件、环路等待条件。
危害
1、线程不工作了,但是整个程序还是活着的。
2、没有任何的异常信息可以供我们检查。
3、一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对正式已发布程序来说,这是个很严重的问题。
如何避免
避免死锁常见的算法有有序资源分配法、银行家算法,关键是保证拿锁的顺序一致。两种解决方式:
1、 内部通过顺序比较,确定拿锁的顺序;
2、采用尝试拿锁的机制(Lock)。
七、其它问题
活锁:两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。
解决办法:每个线程休眠随机数,错开拿锁的时间。
线程饥饿:低优先级的线程,总是拿不到执行时间。
总结
Java并发编程:synchronized、Lock、ReentrantLock以及ReadWriteLock的那些事儿