计算机基础#
CPU缓存#
这是最基础的本科大一讲的:
-
因为CPU的速度比主内存快很多,所需CPU内部有高速缓存,每个线程都会把主存数据load到高速缓存进行计算。这就导致了一个多线程的多份线程缓存中的数据可能和主内存不一致:
- 数据获取流程(速度递减):
L0寄存器-L1级缓存-L2级缓存-L3级缓存(高端CPU才有)-L4主存-L5磁盘-L6远程文件
(寄存器和内存读写速度1:100左右) - L1-L3是CPU的三级高速缓存。
- 多个CPU共享主存,一个CPU内部的多核共享L3,一个核心内部共享L1和L2.
- 数据获取流程(速度递减):
处理器优化和指令重排#
- 因为CPU的速度比主内存快很多,所以不可能一条指令一条指令挨着执行。某些没有依赖关系的指令可能会重排序。(运行时重排)
带来的问题:#
因为CPU使用了高速缓存、而且是多核心的,会有指令重排。会带来CPU硬件层面的问题-----------缓存不一致和原子性、可见性问题。在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致
。
缓存不一致解决方案:缓存一致性协议
原子性和有序性问题解决方案:总线锁来解决
CPU缓存一致性协议:#
下面讲的和Volatile没关系,是CPU层面的类似场景下的实现技术。
- 总线锁:CPU和内存的通信锁住,期间不允许线程访问其他地址的数据,开销太大,不合适。------------汇编lock指令,不过可以解决原子性问题。
- 缓存一致性协议(有多个实现):每个缓存的缓存控制器除了读写自己的数据,还要监听其他缓存的数据。规定了其他各个缓存处于什么状态能否读/写。这也造成了指令重排。(假如当前修改的是CPU0,其他CPU线程简称为CPU1)过程就是CPU0引入了storebuff,将数据的修改执行放到storebuff,然后发送消息给CPU1,这时候CPU0可以继续执行接下来的代码,当storebuff收到CPU1线程的ack应答消息后,storebuff将修改的数据同步到缓存行,再同步到主内存当中。
- MESI是缓存一致性协议的一种,Intel的X86架构实现规范。Modify(修改了)/Exclusive(独占)/Shared(都可以读)/Invalid(失效)。
缓存行与缓存行对齐:#
缓存行CacheLine:#
-
如果内存一个很小的byte和CPU交互,缓存到L3。但是只交互一个很小的对象浪费,也不经济。所以经常是把这个值旁边的一小块内存一起访问(按页读取),下次要读取的时候直接就有了(局部性原理)
-
缓存行越大,局部空间越大效率高,但是读取慢。
-
缓存行越小,局部空间越小效率低,但是读取快。
-
目前多用64字节
上下文切换#
- Java的线程主流JVM是映射到OS的线程上的。阻塞或者唤醒线程需要OS帮忙,是重量级操作,用户态和内核态转换,耗费不少时间。
- 所以在每个线程消耗时间很短的场景下,太多线程不一定比单线程快,频繁的线程切换可能反而带来更大的开销。
降低上下文切换的方法:
- 无锁并发
- CAS
- 尽量用最少的线程并发
- 考虑使用协程
线程间通信#
内存共享
和传递消息
内存共享:在共享内存的并发模型中线程之间共享程序的公共数据状态,线程之前通过读写内存中的公共内存区域来进行信息的传递,典型的共享内存通信方式就是通过共享对象来进行通信。
消息传递:比如在Linux系统中同步机制有管道、信号、消息队列、信号量、套接字这几种方式。JVM的wait()
跟notify()
并发编程问题#
前面说的都是跟硬件相关的问题,软件在这样的硬件层面上运行就会出现原子性
、可见性
、有序性
问题。 其实,原子性问题,可见性问题和有序性问题。是人们抽象定义出来的。而这个抽象的底层问题就是前面提到的缓存一致性、处理器优化、指令重排问题。
一般而言并发编程,为了保证数据的安全,需要满足以下三个特性:
- 原子性:(不可分)指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。只有6个
- 基本数据类型的访问、 读写都是具备原子性的
- synchronized实现原子性
- 可见性:(变更立即可见)指当一个线程修改了共享变量的值时, 其他线程能够立即得知这个修改。三个关键字都能实现:
- volatile:新值立即同步到主存,每次从主存刷新。
- synchronized:对一个变量执行unlock操作之前, 必须先把此变量同步回主内存中(执行store、 write操
作)- final:构造器构造完对象,final的值被赋值。其他线程就能立刻看到。
- 有序性:(顺序)程序执行的顺序按照代码的先后顺序执行。
- volatile:禁止指令重排
- synchronized:一个变量只允许一个线程获取锁。
- Java原生就有Happens-Before原则来解决大部分有序性问题。
你可以发现缓存一致性
问题其实就是可见性
问题(CPU缓存层面的,和Java不一样)。而处理器优化
是可以导致原子性
问题的。指令重排
即会导致有序性
问题。
内存模型#
为了解决因为缓存一致性、处理器优化、指令重排问题导致的上面的并发编程问题,需要提出内存模型
。
内存模型解决并发问题主要采用两种方式:限制处理器优化
和使用内存屏障
。
JMM Java虚拟机内存模型#
JMM是啥?#
因为CPU的高速缓存和指令重排序,带来CPU硬件层面上的原子性、一致性、有序性问题。CPU各个架构采用了不同的方式来解决(总线锁+不同的缓存一致性协议),Java作为一个跨平台的语言,定义一套JMM来屏蔽底层硬件架构的差异,解决上述三个问题。
所以JMM是Java层面的内存模型的实现,为了解决上面的问题,不是实际的存在。。
只要提到Java内存模型,一般指的是JDK 5 开始使用的新的内存模型,主要由JSR-133: JavaTM Memory Model and Thread Specification 描述。
是一种虚拟的规范,作用于工作内存
和主存之间
数据同步过程。
目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。(可见性、原子性、有序性)
JMM 本身是一种抽象的概念并不是真实存在,它描述的是一组规定或则规范,通过这组规范定义了程序中的访问方式。
JVM基本规定:#
- 所有的变量(成员、静态字段)都存储在主内存中。 (此处的变量 与Java编程中所说的变量有所区别, 它包括了实例字段、 静态字段和构成数组对象的元素, 但是不包括局部变量与方法参数,因为后两个是私有的)
- 每条线程还有自己的工作内存。(可以和物理内存和高速缓存类比,但不是一回事儿)
- **线程的工作内存中保存了被该线程使用的变量的主内存副本。**线程对变量的操作(读取赋值等)必须都工作内存进行。
- 线程对变量的所有操作(读取、 赋值等) 都必须在工作内存中进行, 而不能直接读写主内存中的数据。
- 不同的线程之间也无法直接访问对方工作内存中的变量, 线程间变量值的传递均需要通过主内存来完成。
JMM 同步规定
- 线程加锁前,必须读取主内存的最新值到自己的工作内存
- 线程解锁前,必须把共享变量的值刷新回主内存
- 加锁解锁是同一把锁
- 上述三点保证了synchronized的可见性。
JMM和Java内存区域堆栈结构没关系:#
这里所讲的主内存、 工作内存与第2章所讲的Java内存区域中的Java堆、 栈、 方法区等并不是同一个层次的对内存的划分, 这两者基本上是没有任何关系的。 如果两者一定要勉强对应起来, 那么从变量、 主内存、 工作内存的定义来看, 主内存主要对应于Java堆中的对象实例数据部分, 而工作内存则对应于虚拟机栈中的部分区域。(《深入理解JVM虚拟机第三版》)
主内存直接对应于物理硬件的内存, 而为了获取更好的运行速度, 虚拟机(或者是硬件、 操作系统本身的优化措施) 可能会让工作内存优先存储于寄存器和高速缓存中, 因为程序运行时主要访问的是工作内存。
JMM的解决方案#
1.共享对象对各个线程的可见性
,类似CPU的缓存一致性【可见性】#
A 线程读取主内存数据修改后还没来得及将修改数据同步到主内存,主内存数据就又被B线程读取了。
volatile解决、synchronized可以解决(单线程+解锁前刷新)、final天然解决。
2.共享对象的竞争
现象【原子性】#
AB两个线程同时读取主内存数据,然后同时加1,再返回。
synchronized、JUC.Lock (底层都是CAS解决)
3.编译器指令重排【有序性】#
--------此处是编译器的指令重排+CPU指令重排。
- 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- CPU的指令级并行的重排序:现代处理器采用了指令级并行技术将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序:处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
使用volatile禁止指令重排、使用synchronized/JUC同步锁解决。
- 综上,我们发现synchronized似乎是万能的,但是代价也很大。
as-if-serial 和 happens-before#
as-if-serial#
上面编译器和CPU都可能重排,但是要保证不管怎么重排,至少在单线程上运行结果不能改变。所以为了这个目标,编译器和CPU都不会对数据依赖关系的指令重排,这会破坏as-if-serial
规则。(比如读取,应该在写的后面) 强调重排后单线程内的执行结果也不应该改变。
1 | a=1; |
Happens-Before#
是JVM的重排必须遵守的规则。
先行发生
是Java内存模型中定义的两项操作之间的偏序关系, 比如说操作A先行发生于操作B, 其实就是说在发生操作B之前, 操作A产生的影响能被操作B观察到, “影响”包括修改了内存中共享变量的值、 发送了消息、 调用了方法等。
- 程序顺序规则(Program Order Rule):一个线程中的每个操作,happens-before于该线程中的任意后续操作。(线程内是有序的,包括调用、判断)【这是编程基础,否则就乱了】
- 监视器锁规则(Monitor Lock Rule):对一个锁的解锁,happens-before于随后对这个锁的加锁。(先解锁了才能加锁)
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。(先写完了后面才能读,不能重排)
- 线程启动规则(Thread Start Rule) : Thread对象的start()方法先行发生于此线程的每一个动作。
- 。。。还有几个,一共八种,不用背。
Java无需任何同步手段保障就能成立的先行发生规则有且只有上面这些。其他的都可能被重排序。【强调的是,正确同步的多线程程序是按照Happens-Before执行的】
二者关系:#
- as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
- as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
- as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
对象的创建过程#
1 | class T{ |
这个对象的Java字节码:
一共有5条指令,核心三条:(可能重排序)
-
先创建对象,0值初始化。
-
然后调用构造方法,对象属性赋值。
-
最后把引用和对象关联起来。
1 | 0 new #2 <T> # 就像在C++里面获取空间一样,给这个对象使用。并0值初始化。此时m=0-------此时像是一个半初始化状态 |
Volatile关键字#
volatile的两个作用:线程可见性、和防止指令重排序 ,但不能保证原子性!!!
如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
线程可见性:#
JVM的内存也分为主内存、线程本地内存。不要把它和CPU的缓存去对应,事实上主内存和线程本地内存都可能存在于CPU的缓存区、主内存区。
- 这里的“可见性”是指当一条线程修改了这个变量的值, 新值对于其他线程来说是可以立即得知的。
- 而普通变量并不能做到这一点, 普通变量的值在线程间传递时均需要通过主内存来完成。
- 比如,线程A修改一个普通变量的值, 然后向主内存进行回写, 另外一条线程B在线程A回写完成了之后再对主内存进行读取操作, 新变量值才会对线程B可见。
第一个:主内存和线程本地内存:
线程会把主内存的数据copy到本地内存去执行。当变量被volatile修饰后就不会copy到各个线程本地。每次读取的时候都去主内存拿。
1 | // 加了volatie,线程才对其他线程的修改可见 |
volatile只能保证可见性,不能保证并发线程安全性:#
保证可见性,但是并不是说volatile的就是线程安全的!!只是每个线程使用的时候去主存刷新一下而已。
- 第一项是保证此变量的变更对所有线程的可见性,只是对所有线程是可立即得知的,没有一致性问题。(每个线程物理存储的值可能不一样,但是使用前都会去主存刷新,实际使用时候应用看不到不一样的场景)但还是可能有并发原子性问题。
1 | /** |
禁止指令重排(重点)#
volatile通过内存屏障解决指令重排#
-
前面提到为了节省CPU的运行时间,允许将没有数据先后依赖顺序要求的指令重排序运行。
-
但有时候在高并发场景会出现问题,如DCL不用volatile禁止重排,可能会导致极端情况半初始化对象被返回回去(0值未在构造方法顺利初始化)。
内存屏障#
内存屏障
是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。- Java编译器也会根据内存屏障的规则禁止重排序。
- Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。告诉CPU:
- 不管什么指令都不能和这条
Memory Barrier
指令重排序。 Memory Barrier
所做的另外一件事是强制刷出各种CPU Cache
,如一个Write-Barrier
(写入屏障)将刷出所有在Barrier
之前写入cache
的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。- 四种内存屏障:
LL
:序列:Load1,Loadload,Load2 读 读 大白话就是Load1一定要在Load2前执行,及时Load1执行慢Load2也要等Load1执行完。通常能执行预加载指令/支持乱序处理的处理器中需要显式声明Loadload屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。 而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。SS
:序列:Store1,StoreStore,Store2 大白话就是Store1的指令任何操作都可以及时的从高速缓存区写入到共享区,确保其他线程可以读到最新数据,可以理解为确保可见性。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么它需要使用StoreStore屏障。LS
:序列: Load1; LoadStore; Store2 大致作用跟第一个类似,确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。SL
:序列: Store1; StoreLoad; Load2 确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。 StoreLoad Barriers是一个全能型
的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。
JVM层面对volatile的实现#
- 上面提到禁止指令重排底层是通过
内存屏障
实现的。 - 内存屏障其实就是对指令加屏障,在指令执行前后进行约束。JVM编译的时候,生成字节码底层是插入lock 指令-----总线锁
- JVM有8种基础原子操作:
lock
、unlock
、store
、load
。。。(这里的lock和上面的汇编lock不是一回事儿)
如:
- 1.在每个volatile写(S)操作的前面插入一个StoreStore屏障(上一个S和这个S不能重排,前面写完了这里才能写)
- 2.在每个volatile写操作的后面插入一个SotreLoad屏障(上一个S写完了,下面才能读Load)
- 3.在每个volatile读(L)操作的前面插入一个StoreLoad屏障(上一个写完了你才能读)
- 4.在每个volatile读操作的后面插入一个LoadStore屏障(这一次读完了,下面才能写)
综上:volatile变量读操作的性能消耗与普通变量几乎没有什么差别, 但是写操作则可能会慢上一些, 因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
Hotspot底层对volatile的实现(汇编 lock addl 0
)#
和缓存一致性协议没关系,很多人误解这里是MESI实现的,然而并不是。
JVM这里判断是否多核CPU,如果是的话,使用了汇编语句。AMD和其他CPU都用了一样的语句
1 | lock addl 0 esp |
-
addl
是往某个寄存器上面加一个值,这里加了一个0.addl 0
是一个空操作。 -
lock
用于多处理器的时候执行指令时对共享内存独占使用-----锁总线。- 作用是对当前处理器的缓存内容刷到内存,并使其他处理器的缓存失效。
- 同时其他的指令无法越过这个内存屏障。
-
汇编还有一条
nop
空指令,但是不能被lock
-
这里是用
lock
锁住了空操作指令
- 下面的例子如果发生指令重排,可能把标识
initialized = true;
对应的字节码指令重排到读取配置文件configText = readConfigFile(fileName);
前面 - 此时另一个线程看到
initialized = true;
就去读取,但是config
还没准备好,是未初始化的0值对象。 - 所以必须加volatile
1 | Map configOptions; |
final的内存屏障#
1 |
|
在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
也就是赋值完了才能返回,否则会返回一个未初始化final的对象。
- 会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore屏障。
- 读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障。
重量级锁Synchronized#
synchronized关键字是并发编程中线程同步的常用手段之一,synchronized是悲观锁,是一个非公平的可重入锁。其作用有三个:
并发编程问题 可以看到synchronized
有三个功能
Synchronized可以保证:#
- 原子性:加锁的部分一次性完成。多个线程操作同个代码块或函数必须排队获得锁。
- 可见性:Synchronized在结束之前会把其中的所有变量写到共享内存,保证多线程可见。
- 有序性:解决重排序问题。同一时刻只允许一个线程操作代码块。多个synchronized只能逐个串行。
有三种表现形式:#
- 对于静态同步方法,锁是当前类的Class对象。
- 对于普通同步方法,锁是当前实例对象。
- 对于同步方法块,锁是Synchonized括号里配置的对象。
- synchronized修饰的实例方法,多线程并发访问时,只能有一个线程进入,获得对象内置锁,其他线程阻塞等待,但在此期间线程仍然可以访问其他方法。
- synchronized修饰的静态方法,多线程并发访问时,只能有一个线程进入,获得【类锁】,其他线程阻塞等待,但在此期间线程仍然可以访问其他方法。
- synchronized修饰的代码块,多线程并发访问时,只能有一个线程进入,根据括号中的对象或者是类,获得相应的对象内置锁或者是类锁
- 每个类都有一个类锁,类的每个对象也有一个内置锁,它们是
互不干扰
的,也就是说一个线程可以同时获得类锁和该类实例化对象的内置锁,当线程访问非synchronzied修饰的方法时,并不需要获得锁,因此不会产生阻塞。
JVM的synchronized实现:#
这个话题有坑,有一个编译器层面和底层的实现。还有一个synchronized的锁升级部分。
早期1.5之前的synchronized
是和OS的锁一一对应,很重。所以衍生出ReentrantLock
后来才改进为锁升级流程。
synchronized关键字经过Javac编译之后, 会在同步块的前后分别形成monitorenter
和monitorexit
这两个字节码指令插入到synchronized进入和出口位置。 这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
在执行monitorenter
指令时, 首先要去尝试获取对象的锁。 如果这个对象没被锁定, 或者当前线程已经持有了那个对象的锁, 就把锁的计数器的值增加一, 而在执行monitorexit指令时会将锁计数器的值减一。 一旦计数器的值为零, 锁随即就被释放了。 如果获取对象锁失败, 那当前线程就应当被阻塞等待, 直到请求锁定的对象被持有它的线程释放为止。
如果是锁定的方法,有一个ACC_SYNCHRONIZED
标识。
下面是DCL语句对应的JVM字节码:
1 | synchronized (DCL_Singleton.class){ |
对应的字节码,先javac
编译成class,再javap -v
显示这个class的附加信息,就能看到JVM指令
synchronized和CAS底层是这个汇编命令,在Hotspot里面可以看到
1 | lock cmpechg |
存储位置:#
对象布局:
mark word
:8个字节(锁信息/GC代数/hashcode),数组是12class pointer
:这个对象是属于那个类的,指向方法区的对象 4个字节- 数组长度:数组对象才有,4字节。
instance data
:成员变量padding
:对齐为8字节的整数倍。
如果是new Object()
,就是8markword+4class pointer+4padding=16字节,顺丰、美团面试原题!!!
1 |
|
synchronized用的锁是存在Java对象头里的。如果对象是非数组类型,则用8字节存储markword。
Java对象头里的Mark Word里默认存储对象的HashCode
、分代年龄
和锁标记位
。(MarWord:hashcode、GC代信息、锁信息)
Wait和Notify必须和synchronized一起用#
- 使用wait()、notify()和notifyAll()时需要先对调用对象加锁。且加锁、解锁、wait、notify必须是同一个锁对象。否则会报错
java.lang.IllegalMonitorStateException
- 调用wait()方法后,线程状态由RUNNING变为WAITING,并将当前线程放置到对象的等待队列。
- notify()或notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()或notifAll()的线程释放锁之后,等待线程才有机会从wait()返回
- notify()是吧一个线程从等待队列移动到同步队列,notifAll是吧所有线程都移动了。被移动的线程状态从
WATING
变为BLOCKING
各种锁的大全#
乐观锁和悲观锁:#
- 悲观锁:认为竞争是一直存在的,需要加锁来保证执行的顺序,最终保证我们的业务数据的正确性。lock和synchronized都是悲观锁-----适用于写多的场景。
- 乐观锁:认为竞争是少的,大部分时候都没什么竞争,读取更多。这时候可以通过版本号或者去多次自旋去加乐观锁,如果写失败通过再次加锁重试、或者抛出异常等方式来实现业务逻辑。比如CAS的多次自旋锁、AtomicXXX。-------适用于写少的场景
自旋锁和适应性自旋锁:#
- 自旋锁:常规重量级锁是由OS实现的,线程状态切换涉及到内核调用。如果只是一个极小的操作加重量级锁反而效率低。这时候使用CAS自旋锁在用户态就搞定了。(如AtomicInteger底层的getAndAdd)
- 自旋锁的问题:时间短ok,获取锁时间长则会长时间自旋,占用CPU。也不可能通过sleep去操作,只能在自旋一定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有后进行自适应自旋,升级为线程挂起的重量级锁。
- 自适应自旋:默认10次,可以使用-XX:PreBlockSpin来更改。在自旋10次还CAS失败,就进行自适应自旋,升级为线程挂起的重量级锁。
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。(基于统计值的自适应,常见实现:TicketLock、CLHlock和MCSlock)
无锁(轻量级锁)VS偏向锁 VS 轻量级锁 VS 重量级锁#
这四种锁是指锁的状态,专门针对synchronized的。参见后面的 锁升级过程 章节。
公平锁和非公平锁#
公平锁:等待队列严格FIFO,等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁:加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
ReentrantLock
默认非公平。 synchronized也是非公平
另一篇博文《AQS#公平锁和非公平锁》 中可以看到。
其实ReentrantLock中就是下面这一行,多了一个判断。公平锁是判断队列里面没有等待的节点,采取获取锁,失败才排队。非公平锁直接去获取,失败就排队。
可重入锁和不可重入锁#
可重入锁:是在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
不可重入锁:相反,即使是锁定 同一个对象或者class,每次进入都需要单独获取锁。这样在递归或者继承的时候可能会死锁。
首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
可重入锁是加锁status+=1,同一线程可以加锁多次。释放时候status-=1,当status==0就free了。
不可重入锁加锁status+=1,再次加锁不为0会加锁失败。
独享锁 VS 共享锁(读写锁!)#
独享锁:也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
共享锁:是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
下面是JDK的读写分离锁代码实现:
读写分离锁,在读比较多(耗时)的场合比常规的重入锁更加有效率。
- 读-读线程互不阻塞,多少各线程都可以并行一起读。
- 但是当
读-写
或者写-写
线程相互竞争的时候会阻塞获取锁才可以操作
可以看到ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
那读锁和写锁的具体加锁方式有什么区别呢?在了解源码之前我们需要回顾一下其他知识。
高类聚低耦合的要求下,线程要操作资源类。
可重入锁(递归锁):同一线程外层已经获取锁后,内层递归函数任然可以获取锁(锁的标志位+1)。线程可以进入任何一个他已经拥有锁同步者的代码块。
1 | public synchronized void method1(){ |
一把锁加锁2次,解锁2次,正常编译、运行?。-----------阿里电话面试。
1 | /** |
LongAdder底层是分段锁,分段CAS,超高并发的时候比AtomicXXX效率更高。
自旋锁,更多见CAS博文。
循环的方式去获取锁,减少线程切换,但是消耗CPU。
手动基于CAS Thread实现一个自旋锁
读写锁,读不互斥。
锁升级过程:#
先概述:偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。
锁升级是通过ObjectMonitor
监视器实现的。路径是new-偏向锁-轻量级所(无锁、自旋锁、自适应锁)-重量级锁
Monitor#
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
现在话题回到synchronized,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。
“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
1.普通对象-无锁#
- new出来的对象如果偏向锁没启动就是一个普通对象。(极少)后续批量锁撤销和批量重偏向
- 一般4s后就偏向锁就自动启动了,new出来的普通对象就是101的匿名偏向。(大多)
- 多线程CAS去修改一个变量也可视为无锁,失败就重试。
2.偏向锁#
-
101
:普通对象synchronized上锁的时候优先上偏向锁。 -
MarkWord上面记录当前线程指针,下次在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁(线程指针)。(绝大部分时候没有竞争)
-
所谓偏向锁,偏向加锁的第一个线程。hashCode备份在线程栈上,线程销毁,锁降级为无锁。
-
延迟启动,JVM 启动4s以后
-XX:BiasLockStartupDely=0
。所有不加锁对象创建成功就是101
,即匿名偏向锁
,后面没有线程号。 -
竞争严重的应用可以关闭偏向锁,避免
锁撤销
过程的性能消耗。 -
为什么要用到偏向锁?
- 比如
Vector
和StringBuffer
的所有方法都是synchronized
的,StringBuilder
不是。不过StringBuffer
和StringBuilder
底层都是字符串数组去不断扩容实现的。 - 但是在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。也就是绝大部分时候都不需要用到竞争,或者说没有竞争。如果一上来就给操作系统申请重量级线程锁很浪费资源。提高效率。
- 所以就有一个偏向锁,给对象一个标识说当前线程正在占用。(偏向第一个线程)
- 比如
-
偏向锁的释放?
-
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。
-
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
-
如果一上来就竞争特别激烈的场景,不如直接使用轻量级自旋锁。
-XX:-UseBiasedLocking=false
可以关闭默认打开的偏向锁。
-
3.轻量级锁–自旋锁#
-
00
:偏向锁有轻度竞争,偏向锁升级为轻量级锁**(就是自旋锁)**。 -
每个线程有自己的LockRecoder在自己的线程栈上。
-
用CAS去争用markword的LR的指针。指针指向那个线程的LR,哪个线程就有锁。
-
当线程很多的时候,CAS的空转很影响效率,这时候就是用重量级锁去交给OS内核态队列处理,不占CPU。(锁定时间长的也使用synchronized去重量级锁)
-
自旋锁在JDK1.4引入,需要加参数
-XX:+UseSpinning
启用。1.6开始自动启用,并且引入了自适应的自旋锁(适应性自旋锁)
加锁过程:
- 当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
- 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
- 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
- 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
- 若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
4.重量级锁#
10
:JDK1.6以前默认,可以调优:自旋超过10次,或等待的线程超过CPU核数1/2,升级为重量级锁。1.6之后自适应自旋,JVM自己决定升级。- 如果太多线程自旋消耗CPU太大,不如升级为重量级锁,自动加入等待队列,不消耗CPU。
-XX:PreBlockSpin
- 重量级锁也是JVM使用
ObjectMonitor
搞了一个虚拟队列,使用CAS自旋,失败park。(JVM C++代码里,和我们Java的ReentrantLock很像,只是它还搞了一个EntryList另外一个队列去降低竞争)
升级过程记录在markword
上:#
- 其实就是修改下面对象的markword,64位虚拟机看最后两位。所以任何对象都可以被锁:
- 00 轻量级锁(自旋锁)
- 10 重量级
- 11 GC标记要被回收
- 01:001无锁,101偏向锁
普通对象到偏向锁的过程,是2位计数的epoch
,批量锁撤销和批量重偏向。
锁升级为偏向锁后hashCode
存到线程栈中LockRecord
,对象markword记录线程指针。(对象现在被线程独占)
只有重量级锁是在内核态,需要操作系统线程管理介入。前面的偏向锁和轻量级锁都是用户态。
-
自旋锁什么时候升级为重量级锁?
-
偏向锁是不是一定比自旋锁效率高?
- 不一定。在明知道会有很多线程竞争的时候,偏向锁涉及到锁撤销,这时候直接用自旋锁。
- JVM启动过程中,会有很多线程竞争?所以默认的时候不打开偏向锁,过一段时间再打开。
- 不一定。在明知道会有很多线程竞争的时候,偏向锁涉及到锁撤销,这时候直接用自旋锁。
-
轻量级锁和重量级锁的hashcode存储在哪里?
- 线程栈中,轻量级锁的LR中,或者是代表重量级锁的ObjectMonitor的成员在中。
-
为什么有自旋锁还要重量级锁?
- 自旋锁消耗CPU资源,锁的时间太长或者自旋线程太多,CPU大量消耗
- 重量级锁有一个等待队列,拿不到的都在等待,不消耗CPU资源
synchronized优化的过程和mark word息息相关。
如果计算过对象的hashcode,对象无法进入偏向状态。?
锁消除#
锁粗化#
超线程:#
一个AUL+两组Registers+PC
锁重入#
同一线程允许多次进入同一个锁对象代码块。多次进入需要记录加锁次数,后续需要多次解锁。-----上锁一次就在线程栈里记录一个LockRecord,里面有上锁时候的markword等信息。解锁一次就弹出一次。
synchronized是允许锁重入的,否则继承的时候,重写父类synchronized方法super.xxx()
就死锁了。 或者递归synchronized就死锁了。
锁降级#
GCThread才会去看锁对象的状态,降级。不常用
注意:
- 被synchronized修饰的同步块对同一条线程来说是可重入的!!!同一线程反复进入同步块也不会出现自己把自己锁死的情况。(锁计数器+1)
- 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前, 会无条件地阻塞后面其他线程的进入。 这意味着无法像处理某些数据库中的锁那样, 强制已获取锁的线程释放锁; 也无法强制正在等待锁的线程中断等待或超时退出。
持有锁是一个重量级(Heavy-Weight) 的操作,JDK1.6之后好一些。
测试synchronized(this)
和synchronized method()
:相同的,都是对象锁。一个对象只能获取一个。
1 | import lombok.Data; |
输出:
可以看到sam对象同时study,eat,reading三个线程只有一个在运行,但是joe对象和sam对象无关可以并行。这就是对象锁的效果。
加在方法上和加在this上都是一样的效果。
1 | study-thread run.... |
测试类锁和对象锁:
先看结论:
- 同一个对象的多个对象锁互斥(这是废话)
- 类锁和对象锁不互斥,对象锁上锁后,类锁可以继续获取并执行。
- 类锁对多个对象都是互斥的。
- 底层:
- 对象锁:是在new出来的对象的markword上放着锁信息
- 类锁:在类Class的markword上放着锁信息
1 | import lombok.Data; |
输出:
可以看到sam
对象锁多个之间会互斥,一个结束才能继续另一个,和上面一节的测试相同。
sam
的对象锁和类锁不互斥,可以同时study
和eat
sam
和joe
的类锁互斥,sam
的eat
结束后joe
才能eat
1 | study-thread run.... |
DCL单例#
注意最上面的对象要加volatile
,锁里面要二次检查
1 | /** |
线程基础#
创建线程的方式:#
Thread/Runnable/Callable-Futrue/ExcutorServices
一共有六种状态
- NEW
- RUNNABLE
- WATING
- TIMED_WATING
- BLOCKED
- TERMINATED
线程状态迁移过程:#
- 刚刚创建是
New
新建状态,此时还没有执行start方法 - 执行完Start之后就是
Runnable
状态。Running和
Ready都算
Runnable`状态,Running是在CPU运行,Ready是没有争取到时间片,等待执行。 - 带时间的
sleep/wait/park
都会进入TimeWating
状态,时间结束又回到Runnable
状态 - 不带时间的
wait/join/park
都是Waiting
状态,在notify/tonifyAll/unpark
之后回到Runable
- 等待锁的是
Blocked
状态 - 运行完毕是
Terminated
状态 - 以上在arthas里面非常清晰
- 不要使用stop停止线程。
- interrupt是来打断
sleep/wait/join/park
的线程,然后在这些方法里面catch到异常去根据业务规则处理。一般很少使用interrupt去控制业务逻辑,netty里面有,但是写的很完善。 - linux上的实现是轻量级的进程,和进程差距不大。
ObjectMonitor:#
- 每一个对象都有一个与之对应的监视器
- 每一个监视器里面都有一个该对象的锁和一个等待队列和一个同步队列
锁升级是通过ObjectMonitor
监视器实现的。路径是new-偏向锁-轻量级所(无锁、自旋锁、自适应锁)-重量级锁
notify()方法也是一样的,用来唤醒一个线程,你要去唤醒,首先你得知道他在哪儿,所以必须先找到该对象,也就是获取该对象的锁,当获取到该对象的锁之后,才能去该对象的对应的等待队列去唤醒一个线程。
ObjectMonitor
:里面有waiteSet
等待的线程集合,_count
重入数量,entryList
处于等待状态的线程双向链表。
Wait/Notify#
wait方法要放在锁里面!!!#
thread.join的底层是基于wait/notify实现的。wait方法来自Object,是Native的C++方法。
1 | /**等待一定的时间,等不到就挂,传入0则永久等待。 |
wait的实现:#
JVM的wait,都会走ObjectMonitor#wait()
,调用的时候会把当前线程包装成一个C++的ObjectWaiter
对象丢到ObjectMonitor._waitSet
(等待队列)里面。会调用park挂起线程。所以:
- 首先wait的语义就是让这个对象上的线程等待,
- wait首先需要获取当前对象锁
- 然后当前线程放到wait对象的阻塞队列
- 这些操作都是与监视器相关的,当然要指定一个ObjectMonitor监视器才能完成这个操作
Notify的实现:#
唤起的时候,去waitset里面拿到这个线程,给他unpark。
为啥要放到synchronized里面?否则会抛出 IllegalMonitorStateException?#
因为wait的时候要放到ObjectMonitor
的等待队列里面,notify的时候要从ObjectMonitor
里面拿出来
callable/futrue#
常见问题QA#
synchronized和ReentrantLock有什么区别?#
synchronized | ReentrantLock |
---|---|
Java关键字,隐式释放 | Lock接口的实现类,手动获取,finally中释放。(可中断/超时) |
1.6后有优化,一系列锁升级过程,最终是重量级。底层是’lock cmpchg’汇编 | 基于AQS实现,底层是CAS |
独占式,性能相对低 | 可以非独占,可以读写锁分离(共享锁) |
多变量加锁 | 单变量加锁 |
被动 | 主动性高 |
可以tryLock()不管加锁是否成功都继续,也可以tryLock(超时时间),还可以lockInterruptibly在等待锁的时候被打断(同时处理异常,很少用) | |
可以公平锁,可以非公平。一个任务执行完是否会先去检查队列最前面的。 | |
锁升级 | 底层是cas+park/unpark升级 |
有condition等待队列,可以在lock后condition.await() 加入等待队列并释放锁 |
避免死锁:#
死锁就是两个线程互相持有一个锁,都在等待对方的一个锁释放。
- 避免一个线程同时获取多个锁。(最有效的方法)
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况
并发一定会提高效率?#
No,要考虑资源限制。比如网络带宽、磁盘IO、CPU能力等资源的限制下即使再多的线程也无济于事,还会因为线程切换带来额外开销。
- 对于硬件限制可以考虑将进程分到多个机器的集群,最终计算结果聚合。
- 对于软件的限制,考虑使用连接池、线程池、长连接。
- 资源限制条件下:调整并法度,如下载依赖于网速和磁盘读写。不超过CPU线程数等。
实现#
- 打开偏向锁效率一定高么?
- 不一定,当很多线程争抢的时候,偏向锁还需要一个
锁撤销
的过程,把当前线程ID拿掉,效率反而低。 - 比如JVM启动的时候底层会有多线程竞争,这时候直接上轻量级锁,所以延迟4s才会启动偏向锁。
- 不一定,当很多线程争抢的时候,偏向锁还需要一个
类加载技术的半加载技术
新生代到你老年代CMS默认6代,PS+PO、G1 默认15,mark word里4位最大16
频繁FGC,