Synchronized详解及高频面试问答
目录
JVM简述
Synchronized详解及面试高频问答
而synchronized是什么,可以解决什么问题?
synchronized怎么使用?
锁升级升级了什么?
为什么要这样做锁升级?
锁升级的过程是怎样的?为什么会有偏向锁,轻量级锁,重量级锁?
为什么会有偏向锁呢?
什么时候升级到轻量级锁?
为什么要有轻量级锁呢?
自旋的性能一定要比阻塞的性能好吗?
那轻量级锁什么时候升级为重量级锁呢?
为什么要升级到重量级锁?
为什么要有锁监视器呢?
Synchronized的可重入锁如何实现?
锁池与等待池的作用?
想要了解Synchronized,需要先了解JVM(Java内存模型)
JVM简述
Java内存模型将内存分为两种,主内存和工作内存。
并且规定,所有的变量都存储在主内存中(不包括局部变量与方法参数)。
主内存中的变量是所有线程共享的。
每个线程都有自己的工作内存,存储的是当前线程所使用到的变量值。即主内存变量中的一个副本数据。
线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
不同线程间无法直接访问对方工作内存中的变量。
线程间变量值的传递需要通过主内存实现。
这样规定的原因:
是为了屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
关于各种硬件间的内存访问差异
CPU,内存,IO设备都在不断迭代,不断朝着更快的方向努力,但三者的速度是有差异的。
CPU最快,内存其次,IO设备(硬盘)最慢。
为了合理利用CPU的高性能,平衡三者间的速度差异,计算机体系结构,操作系统,编译系统都做了贡献,主要体现为:
-
CPU增加了缓存,以平衡与内存的速度差异,且分为三级缓存,一级和二级缓存是CPU核心私有的,但是第三级缓存是共享的。
这样CPU运算时所需要的变量,优先会从缓存中读取。
缓存没有时,会从主内存中加载并缓存。如下图所示:
事物都是有两面性的,缓存提高了CPU的运算速度,也带来了相应的问题:
当多个线程在不同的CPU上运行并访问同一个变量时,由于缓存的存在,可能读取不到做最新的值,也就是可见性问题。
可见性指的是一个线程对共享变量的修改,另一个线程能够立刻看到,被称为可见性。
-
操作系统增加了进程,线程,以时分复用CPU,进而均衡CPU与IO设备的速度差异
操作系统通过任务的一个切换来减少CPU的等待时间,从而提高效率。
任务切换的时间,可能是发生在任何一条CPU指令执行完之后。
但是我们平时使用的编程语言,如C,Java,Python等都是高级语言,高级语言转换成CPU指令时,一条指令可能对应多条CPU指令。 相当于1=n,这是违背我们直觉的地方。
所以问题来了,著名的count+=1问题就是这个原因。也就是原子性问题。
我们把一个或多个操作在CPU执行的过程中不被中断的特性为原子性。(这里的操作是指我们高级语言中相应的一些操作)
-
编译程序优化指令执行次序,使得缓存能够得到更加合理的利用。
指令重排序可以提高了缓存的利用率,同样也带来了有序性问题。
也就是单例模式问题。
重排序提高缓存利用率的例子:
在平时写代码时,经常会在方法内部的开始位置,把这个方法用到的变量全部声明了一遍。缓存的容量是有限的,声明的变量多的时候 前面的变量可能就会在缓存中失效 。
接下来再写业务时,用到了最先声明的变量 然后发现在缓存中已经失效了,需要重新的去主内存进行加载。
所以指令重排序可以看成编译器对我们写的代码进行的一个优化。就类似于让变量都能用上,不至于等到失效在使用。
所以要想实现在各种平台都能达到一直的内存访问效果,就需要解决硬件和操作系统之间产生的问题:
1.CPU增加缓存最后导致的可见性问题
2.操作系统增加了线程,进程之后出现的原子性问题
3.指令重排序导致的有序性问题
Java内存模型如何解决三个问题?
原子性问题解决方案
-
JVM定义了8种操作来完成主内存与工作内存之间的数据交互,虚拟机在实现时需要保证每一种操作都是原子的,不可再分的。
Java中基本数据类型的访问、读写都是具备原子性的(long和Double除外),更大的原子性保证:Java提供了synchronized关键字(synchronized的字节码指令monitor enter和monitor exit来隐式的使用了lock和unlock操作),在synchronized块之间的操作也具备原子性。
八种操作: lock,unlock,read,load,assign,use,store,write
CAS(乐观锁),比较并替换,(Compare And Swap),CAS是一条CPU的原子指令(即cmpxchg指令),Java中的Unsafe
类提供了相应的CAS方法,如(compareAndSwapXXX)底层实现即为CPU指令cmpxchg
,从而保证操作的原子性。
可见性问题与有序性问题解决方案
-
JVM定义了Happens-Before原则来解决内存的不可见性与重排序的问题。
Happens-Before规则约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后要遵守Happens-Before规则。
Happens-Before规则:
对于两个操作A和B,这两个操作可以在不同的线程中执行,如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作时可见的。
8种Happens-Before规则
程序次序规则、锁定规则、volatile变量规则、线程启动规则、线程终止规则、线程中断规则、对象终结原则、传递性原则。
volatile变量规则(重点):对一个volatile变量的写操作先行发生于后面的这个变量的读操作。
Synchronized详解及面试高频问答
而synchronized是什么,可以解决什么问题?
synchronized就是锁,能保证某段代码同一时间只能被拿到锁的线程访问,这就保证了原子性。
synchronized编译之后就是monitor enter和monitor exit两个指令。
monitor enter就是 加锁(lock),加锁的时候会使用读屏障,强行去从主内存中重新读取数据,也就是说他能保证读到的数据都是最新的数据。
monitor exit就是 解锁(unlock)。 解锁时通过写屏障保证强制将CPU缓存中的变量刷新到主内存中,能够保证线程修改后的数据对其他线程立即可见,这就是保证了可见性。
synchronized能够通过内存屏障防止指令重排,这就保证了有序性。
synchronized怎么使用?
synchronized修饰普通方法锁的就是this(当前对象),也就是说一个对象用一把锁,修饰静态方法锁 的就是类.class对象(类的字节码对象),也就是类的所有对象共用一把锁。
synchronized修饰代码块,那就是代码块中写什么就锁什么。
Synchronized在JDK1.6时做了锁升级以提高Synchronized锁的性能。
Synchronized加锁的原理其实是调用操作系统底层原语mutex。
然后又涉及了线程的阻塞与唤醒,Java线程模型是一对一的,每一个Java线程都直接对应一个操作系统的内核级线程,每次切换线程都需要操作系统从用户态切换到内核态,消耗的性能很大。这也就是JDK1.6版本前Synchronized的问题所在。
锁升级升级了什么?
在低并发的情况下,锁竞争比较少的情况下,不让其阻塞线程,只要线程不阻塞,操作系统就不需要从用户态切换到内核态,提高性能。
在高并发,锁竞争较为激烈的情况下,再让其去阻塞线程。
锁升级的过程:无锁 --->偏向锁---> 轻量级锁---> 重量级锁
为什么要这样做锁升级?
在实际开发中,一个系统大多数的时候都是不存在锁竞争的,经常就只有一两个线程去拿锁。
例如:
商城系统24小时运行,从凌晨一点到早上六点这段时间不会有太多人会购物,这段时间是没有锁竞争的。
高并发的时候往往发生在固定的时间点,其他时间并发量都不大。所以绝大多数系统没什么并发量。
即便是高并发系统,也不时时刻刻有高并发,绝大多数时间其实都只有一个或者几个线程进行锁竞争,
为了在低并发的时候降低获取锁的代价,为了提高低并发时的性能,所以做了锁升级。
锁升级的过程其实是为了应付越来越激烈的锁竞争的过程。
锁升级的过程是怎样的?为什么会有偏向锁,轻量级锁,重量级锁?
最开始我们的系统是无锁状态,当有第一个线程来访问同步代码块时,JVM将对象头的Mark Word锁标志位设置为偏向锁。
然后将线程ID记录到Mark Word中,这是线程进入同步代码块,就不需要其他的同步操作了,性能得到了极大的提高。
为什么会有偏向锁呢?
偏向锁考虑的是只有一个线程抢锁的场景
什么时候升级到轻量级锁?
当有第二个线程来抢锁的时候就升级为轻量级锁,第二个线程拿不到锁就采用CAS加自旋的方式不断重新尝试获取锁。
为什么要有轻量级锁呢?
轻量级锁考虑的是竞争锁的线程不多,而且线程持有锁的时间也不长的一个场景。
因为阻塞线程需要操作系统从用户态切换到内核态,性能消耗较大,如果刚阻塞了这个线程,紧接着这个锁就被释放了,这会造成性能的损耗,且得不偿失。
因此在轻量级锁期间直接不阻塞线程,让其自旋等待锁释放。
自旋的性能一定要比阻塞的性能好吗?
自旋是让线程一直循环执行抢锁命令,线程一直在运行,他没有被阻塞,所以就减少了线程上下文切换的一个开销,操作系统不需要切换到内核态了,短时间的自旋性能是不错的。
但长时间的自旋会让CPU一直在空转,CPU没有办法去执行其他任务,会浪费CPU,所以这里轻量级锁只是在两个线程竞争的场景下使用。
且这里的自旋是适应性自旋,自旋的时间由上一个线程自旋的时间去决定。
可以知悉,在偏向锁期间只记录线程ID,而轻量级锁只是自旋抢锁,它们都没有去做操作系统级的线程阻塞与切换,不需要操作系统切换到内核态去做操作,整个过程在用户态操作即可,所以在较低的锁竞争时,偏向锁与轻量级锁的设计就提高了性能。
而在系统中绝大所数的时候锁竞争其实是比较低的。
那轻量级锁什么时候升级为重量级锁呢?
当第二个线程自旋到一定次数之后还是没拿到锁或获取锁失败,或者当有不少其他的线程来抢锁了,那就升级为重量级锁。
就需要调用操作系统的底层原语mutex,所以每次切换线程都需要操作系统从用户态转换成内核态,性能损耗很大。(这也是称为重量级锁的原因)
为什么要升级到重量级锁?
因为自璇只适合锁竞争比较小、而且执行时间比较短的一个程序,但如果有大量的线程去抢夺锁,就需要大量的线程去自旋,这样十分浪费CPU的算力。
当某个线程执行任务的时间非常长,那么其他线程自旋的时间也会被无限拉长,直至任务完成,锁释放,长时间的自旋会导致会让CPU一直空转,让CPU无法执行其他任务,浪费CPU的算力。
所以当第二个线程自旋到一定次数之后还是没拿到锁或获取锁失败,或者当有不少其他的线程来抢锁的时候,就需要直接进入重量级锁了。
将那些没有拿到锁的线程阻塞,这时候操作系统由用户态切换成内核态时的性能损耗没有多个线程自旋的性能损耗大
当升级到重量级锁时,对象头的 Mark Word 的指针会指向锁监视器 monitor。
为什么要有锁监视器呢?
锁监视器主要用来负责记录锁的拥有线程,记录锁的重入次数,负责线程的阻塞唤醒。
具体来说锁监视器就是一个对象,有以下几个重要属性
class ObjectMonitor{void* _owner;// 持有锁的线程WaitSet _WaitSet;// 等待池 (管理调用wait()方法的线程)EntryList _EneryList;// 锁池(管理因竞争锁失败而阻塞的线程)int _recursions;// 记录锁的重入次数//其他不重要的字段};
owner字段:记录持有锁的线程。
重入次数计数器:当一个线程重复的去获取这个锁,这就是可重入锁(ReentrantLock)。
Synchronized的可重入锁如何实现?
就是依赖锁监视器内部的重入次数计数器,重入一次计数器加一次,释放一次计数器减一次,减到零就完全释放锁了。
锁池与等待池的作用?
首先在重量级锁状态,当有线程拿到锁,此时监视器的owner字段就记录拿到锁的线程,没有拿到锁的线程就会被阻塞住,进入blocking(阻塞)状态,然后放在锁池中。
当拿到锁的线程调用了wait方法,那该线程就释放锁,进入waiting(等待)状态,然后放在等待池中。
当某个线程调用了notify()方法唤醒了在等待池中的某个线程,那这个线程就从waiting状态变成blocking状态,然后再被放进锁池中,等待锁释放,重新去抢锁。
锁竞争失败的线程和调用了wait方法的线程的区别是什么?为什么要分开放在两个不同的集合中?
锁池放的是竞争锁失败的线程,线程状态是blocking,在这个集合中的线程目标是尽快获取锁去执行任务,这是锁的互斥问题。
等待池中放的是主动放弃锁的线程,这个集合中的线程等待被其他线程唤醒之后去配合其他线程去完成某项任务。线程状态是waiting或time waiting。这些waiting状态的线程事项等待某种资源到位或者某项任务完成之后,再被notify()方法唤醒,再放入锁池中,准备去抢锁做业务。这是线程通信问题。
所以等待池中的线程与锁池中的线程的目标与解决的问题截然不同。自然要放在两个不同的集合中了。
希望对大家有所帮助!