在文章的开头先明确几个概念:
- 并发:多个线程同时操作同一个对象,并要修改其实例变量
- final修饰的实例变量线程安全,因为不可变只能初始化一次
- 锁:OS的调度无法满足同步的需求,需要程序通过调度算法协助调度
- synchronized:jvm级别锁
- Lock:api级别
- synchronized:对象的锁,锁的代码
- 通过只允许一个线程执行sync内代码,保证了可见性,有序性,原子性
- 并发要求线程交替执行(时间片),而拿了锁会一直将任务执行完再释放(即使n时间片)
- java的线程通信实际是共享内存
- synchronized是悲观锁,独占锁,非公平锁,可重入锁
- 悲观锁 <==> 乐观锁(CAS:注意ABA问题):是否一定要锁
- 独占锁 <==> 共享锁(读锁、写锁):是否可以有多个线程同时拿锁
- 非公平锁 <==> 公平锁:是否按阻塞顺序拿锁
- 可重入锁 <==> 不可重入锁:拿锁线程是否可以多次拿锁
1.JDK6之前:重量级锁
1.1 JVM层面:monitor
JVM基于进入和退出 Monitor 对象来实现方法同步和代码块同步
两条指令:monitorenter和monitorexit
每一个 Java 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被 synchronized 修饰的同步方法或者代码块时,该线程得先获取到 synchronized 修饰的对象对应的 monitor。当一个 monitor 被持有后,它将处于锁定状态。
- 在进入加锁代码块的时候加一个monitorenter的指令,然后针对锁对象关联的monitor累加加锁计数器,同时标识自己这个线程加了锁,通过monitor里的加锁计数器可以实现可重入的加锁。
- 在出锁代码块的时候,加一个monitorexit的指令,然后递减锁计数器,如果锁计数为0,就会标志当前线程不持有锁。
那这个monitor监视器到底是什么呢?又怎么和具体的对象关联起来的呢?
C++实现:ObjectMonitor
这个monitor不是java实现的。是C++实现的一个ObjectMonitor对象,里面包含了:一个_owner指针,指向了持有锁的线程;一个entryList,想要加锁的线程全部先进入这个entrylist等待获取机会尝试加锁;一个waitSet,当获取锁的线程执行了wait就会进入waitset。
- 实际有机会加锁的线程,就会设置_owner指针指向自己,然后对_count计数器累加1次;
- 如果获取锁的线程执行wait,就会将计数器递减,同时_owner设置为null,然后自己进入waitset中等待唤醒,别人获取了锁执行notify的时候就会唤醒waitset中的线程竞争尝试获取锁。
- 释放锁是会将_count计数器递减1,如果为0了就会设置owner为null,表示自己彻底释放锁。
那尝试加锁这个过程,也就是对_count计数器累加操作,是怎么执行的?如何保证多线程并发的原子性呢?JDK 1.6之后,对synchronized内的加锁机制做了大量的优化,这里就是优化为CAS加锁的。
1.2 OS层面:互斥锁
如上图,synchronized 是如何将线程阻塞在 EntryList 和 waitSet 的?答:是依赖于底层操作系统的 Mutex Lock(互斥锁)来实现的。每个对象都对应于一个可称为" 互斥锁" 的标记,这个标记用于保护临界区,确保同一时间只有一个线程访问该对象的数据。
注:AQS的 LockSupport#park 也是借助互斥量实现的,可以参考这篇文章...
临界区是什么?
一次只允许一个进程使用的共享资源称为临界资源(注:共享资源是可以同时被多个进程访问的资源),如打印机、公共变量等;而在并发进程中与共享变量有关的程序段称为临界区。
对临界区的访问必须是互斥进行,以保证临界资源的安全使用,因此可以将一个访问临界资源的循环进程描述如下:
while(true){
进入区 // 检查临界资源是否被正被访问
临界区 // 操作临界资源的相关代码
退出区 // 将临界区整备访问的标志恢复为未被访问的标志
剩余区 // 进程中除上述三区外的其余部分代码
}
为了实现进程互斥的进入自己的临界区,可以使用软件方法,但更多的是在系统中设置专门的同步机制来协调各进程间的运行。所有同步机制都遵守以下四条准则:
- 空闲让进:当无进程处于临界区时,表明临界资源处于空闲状态,营运寻一个请求进入临界区的进程立刻进入自己的临界区,以有效利用临界资源
- 忙则等待:当已有进程进入临界区时,表明临界资源正在被访问,因而其他试图进入临界区的进程必须等待,以保证对临界资源的互斥访问
- 有限等待:对要求访问临界资源的进程,应保证在有限时间内能进入自己的临界区,以免陷入“死等”状态
- 让权等待:当进程不能进入自己的临界区时,应立即释放处理机,以免进程陷入“忙等”状态
如果每次都要判断临界资源状态,然后再修改修改临界资源状态会使代码较为繁琐,并且稍有不慎就会出现调度出错。所以有没有什么能使用的互斥工具呢?
互斥量是什么?
互斥量即 mutex(mutual exclusive),也便是常说的互斥锁。它的使用思路也很简单粗暴,多个进程共享一个互斥量,然后进程之间去竞争,等得到锁的进程可以进入临界区执行代码。mutex的基本函数如下:
#include<pthread.h> // 以下方法都是成功返回0,失败返回-1
int pthread_mutex_init(pthread_mutex_t *mutex,pthread_mutexattr *attr); // 初始化锁
int pthread_mutex_lock(pthread_mutex_t *mutex); // 对资源加锁,阻塞。
int pthread_mutex_trylock(pthread_mutex_t*mutex); // 对资源加锁,非阻塞
// 临界区...
// 临界区...
int pthread_mutex_unlock(pthread_mutex_t *mutex); // 对资源解锁
int pthread_mutex_destroy(pthread_mutex_t *mutex); // 销毁锁
可以看到,互斥锁(Mutex)与信号量(Semaphore)的函数基本相似,主要区别是信号量是一种同步机制,可以当作锁来用,但也可以当做进程/线程之间通信使用,作为通信使用时不一定有锁的概念;互斥锁是为了锁住一些资源,是为了对临界区做保护:
- 互斥量用于线程的互斥,信号量用于线程的同步
- 互斥:指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排他性。但是互斥无法限制访问者对资源的访问顺序,所以访问是无序的 * 同步:指在互斥的基础上(多数情况),通过其他机制实现访问者对资源的有序访问。大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况指可以允许多个访问者同时访问资源
- 互斥量值只能是0/1,信号量值可以为非负整数
- 一个互斥量只能用于一个资源的互斥访问不能实现多个资源的多线程互斥问题 * 一个信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量时,也可以完成一个资源的互斥访问
- 互斥量的加锁和解锁必须由同一线程分别对应使用;而信号量可以由一个线程释放,另外一个线程得到
2.JDK6之后:锁膨胀
JDK6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。在对象头的Mark Word中保存了不同的锁状态:
2.1 偏向锁
- 特点:只有一个线程执行
- 原理:CAS
- 注:偏向锁在Java 6和Java 7里是默认启用的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
获取锁过程:
- 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。
- 如果为可偏向状态,则测试线程ID是否指向当前线程
- 如果是,进入步骤(5)
- 否则进入步骤(3)
- 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。
- 如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5)
- 如果竞争失败,执行(4)
- 如果CAS获取偏向锁失败,则表示有竞争(CAS获取偏向锁失败说明至少有过其他线程曾经获得过偏向锁,因为线程不会主动去释放偏向锁)。当到达全局安全点(safepoint)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁)
- 如果线程不处于活动状态,则将对象头设置成无锁状态(标志位为“01”),然后重新偏向新的线程;
- 如果线程仍然活着,撤销偏向锁后升级到轻量级锁状态(标志位为“00”),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
- 执行同步代码。
释放锁过程:
如上步骤(4)。偏向锁使用了一种等到竞争出现才释放偏向锁的机制:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
2.2 轻量级锁
- 特点:多线程交替执行
- 原理:自旋 + CAS
- 自适应自旋:一般10次
- 自定义自旋次数:设置 preBlockSpin
- 自旋锁
- 引入自旋锁的原因:互斥同步对性能最大的影响是阻塞的实现,因为挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力。同时虚拟机的开发团队也注意到在许多应用上面,共享数据的锁定状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
- 自旋锁:让该线程执行一段无意义的忙循环(自旋)等待一段时间,不会被立即挂起(自旋不放弃处理器额执行时间),看持有锁的线程是否会很快释放锁。自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启;在JDK1.6中默认开启。
- 自旋锁的缺点:自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理器的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,例如让其循环10次,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起(进入阻塞状态)。通过参数-XX:PreBlockSpin可以调整自旋次数,默认的自旋次数为10。
- 自适应的自旋锁:JDK1.6引入自适应的自旋锁,自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:如果在同一个锁的对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。简单来说,就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
- 自旋锁使用场景:从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。(见前面“轻量级锁”)
获取锁过程:
线程在执行同步块之前,JVM会先在当前的线程的栈帧中创建一个Lock Record,其包括一个用于存储对象头中的 mark word(官方称之为Displaced Mark Word)以及一个指向对象的指针。下图右边的部分就是一个Lock Record。
-
在线程栈中创建一个Lock Record,将其obj(即上图的Object reference)字段指向锁对象。
-
直接通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。如果失败,进入到步骤3。
-
如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分(Displaced Mark Word)为null,起到了一个重入计数器的作用。然后结束。
-
走到这一步说明发生了竞争,需要膨胀为重量级锁。
锁释放过程:
- 遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。
- 如果Lock Record的Displaced Mark Word为null,代表这是一次重入,将obj设置为null后continue。
- 如果Lock Record的Displaced Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为Displaced Mark Word。如果成功,则continue,否则膨胀为重量级锁。
2.3 重量级锁
只有重量级锁才能叫真正意义的锁,偏向锁与轻量级锁相当于无锁(并无线程被阻塞让出CPU)
- 特点:存在并发,多线程要同时运行
- 原理:就是文章前一部分的monitor, 每一个 JAVA 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被 synchronized修饰的同步方法或者代码块时,该线程得先 获取到synchronized修饰的对象对应的monitor。
依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能
2.4 总结:三种锁的MarkWord变化
下图用一张图总结一下锁膨胀过程中Mark Word 的变化情况:
- 一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。
- 一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁;如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
- 轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
文章最后放两个链接,对synchronized在JVM具体实现感兴趣的童鞋有必要一读 死磕Synchronized底层实现--概论,Synchronized原理剖析 !!!