锁作为并发共享数据,保证一致性的工具,在JAVA平台有多种实现。如 synchronized 和 ReentrantLock(并发包中的锁类)等 。
1、锁涉及的几个重要概念
- 死锁
线程之间相互等着对方释放资源,而自己的资源又不释放给别人,这种情况就是死锁。所以,只要其中一线程释放了资源,死锁就会被解除。
- 重入锁
重入锁指的是,一个线程在拥有了当前资源的锁之后,可以再次拿到该锁而不被阻塞。
- 自旋锁
自旋锁指的是,线程在没有获得锁时,不是被直接挂起,而是执行一个空循环(自旋)。默认是循环10次。
自旋锁的目的 :为了减少线程被挂起的几率,因为线程的挂起和唤醒也都是耗资源的操作。
如果锁被另一个线程占用的时间比较长,即使自旋了之后当前线程还是会被挂起,空循环就会变成浪费系统资源的操作,反而降低了整体性能。所以,自旋锁是不适应锁占用时间长的并发情况的。
- 自适应自旋锁
自适应自旋锁是对自旋锁的一种优化。当一个线程自旋后成功获得了锁,那么下次自旋的次数就会增加。因为虚拟机认为,既然上次自旋期间成功拿到了锁,那么后面的自旋会有很大几率拿到锁。相反,如果对于某个锁,很少有自旋能够成功获得的,那么后面就会减少自旋次数,甚至省略掉自旋过程,以免浪费处理器资源。
这种锁是默认开启的。
- 锁消除
锁消除指的是,在编译期间利用“逃逸分析技术”分析出那些不存在竞争却加了锁的代码的锁失效。这样就减少了锁的请求与释放操作,因为锁的请求与释放都会消耗系统资源。
锁消除也是默认开启的。我们知道StringBuffer的append方法是加了锁的,但在下面的情况,它的锁就会失效:
StringBuffer的append()方法中都有一个同步块,但是虚拟机观察stringBuffer对象发现他的动态作用域都在test()方法内部,也就是说stringBuffer的所有引用都不会逃逸出test()方法,因此这里的锁可以被安全的消除掉.
public String test(){
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 1000; i++) {
sb.append(i);
}
return sb.toString();
}
逃逸分析技术,还会将确定不会发生逃逸的对象放在栈内存中而不是堆内存中,所以说,并不是所有的对象都存在堆内存中的。
- 偏向锁
偏向锁指的是,当第一个线程请求时,会判断锁的对象头里的ThreadId字段的值,如果为空,则让该线程持有偏向锁,并将ThreadId的值置为当前线程ID。当前线程再次进入时,如果线程ID与ThreadId的值相等,则该线程就不会再重复获取锁了。因为锁的请求与释放是要消耗系统资源的。
如果有其他线程也来请求该锁,则偏向锁就会撤销,然后升级为轻量级锁。如果锁的竞争十分激烈,则轻量级锁又会升级为重量级锁。
- 锁粗化
锁粗化指的是,在编译期间将相邻的同步代码块合并成一个大同步块。这样做可以减少反复申请和释放同一个锁对象导致的系统开销。锁粗化也是默认开启的。
粗化前伪代码:
synchronized(monitor){
method1();
}
synchronized(monitor){
method2();
}
粗化后伪代码:
synchronized(monitor){
method1();
method2();
}
锁粗化也提醒了我们平时写代码时,尽量不要在循环内使用锁:
// 粗化前
for(int i=0;i<10000;i++){
// 这会导致频繁同步代码,无谓的消耗系统资源
synchronized(monitor){
doSomething...
}
}
// 粗化后
synchronized(monitor){
for(int i=0;i<10000;i++){
doSomething...
}
}
- 类锁和对象锁(重要)
如果你分不清类锁和对象锁,那你在代码中对于锁的使用和分析就很容易出问题。
对象锁占用的资源是对象级别,类锁占有的资源是类级别。
Class A {
// ==>对象锁:普通实例方法默认同步监视器就是this,
// 即调用该方法的对象
public synchronized methodA() {
}
public methodB() {
// ==>对象锁:this表示是对象锁
synchronized(this){
}
}
// ==>类锁:修饰静态方法
public static synchronized methodC() {
}
public methodD(){
// ==>类锁:A.class说明是类锁
synchronized(A.class){}
}
// 普通方法:任何情况下调用时,都不会发生竞争
public common(){
}
}
methodA,和methodB都是对当前对象加锁,即如果有两个线程同时访问同一个对象的methoA或methodB会发生竞争。如果两个线程访问的是不同对象的methodA和methodB则不会发生竞争。
methodC和methodD是对类加锁,即如果两个线程同时访问同一个对象的methodC和methodD会发生竞争,且两个线程同时访问不同对象的methodC和methodD是也会发生竞争。
如果一个线程访问methodA或methodB,另一个线程访问methodC或methodD,则这两个线程不会发生竞争。因为一个是类锁另一个是对象锁。类锁和对象锁是两个不一样的锁,控制着不同的区域,它们互不干扰。
5种类锁示例:
Class A {
// 普通字符串属性
private String val;
// 静态属性
private static Object staticObj;
// ==>类锁情况1:synchronized修饰静态方法
public static synchronized methodA() {
}
public methodB(){
// ==>类锁情况2:同步块里的对象是类
synchronized(A.class){}
}
public methodC(){
// ==>类锁情况3:同步块里的对象是字符串
synchronized("A"){}
}
public methodD(){
// ==>类锁情况4:同步块里的对象是静态属性
synchronized(staticObj){}
}
public methodE(){
// ==>类锁情况5:同步块里的对象是字符串属性
synchronized(val){}
}
}
补充:
两个线程分别访问一个类的静态synchronized和一个静态不加锁方法时,不阻塞。
两个线程分别访问一个类的静态synchronized和一个非静态synchronized方法时,不阻塞。
2、synchronized实现原理
临界区:被同步保护的代码区域。也就是下面字节码中monitorenter和monitorexit指令之间的区域。
public void synchronizedTest() {
synchronized (this) {
System.out.println(" synchronizedTest");
}
}
上述同步代码块对应的字节码:
在字节码中,位置3处有个monitorenter就是申请锁的指令,位置19处有个monitorexit就是释放锁的指令。
监视锁monitor :是每个对象都有的一个隐藏字段。申请锁成功之后,monitor就会成为当前线程的唯一持有者。线程第一次执行monitorenter指令后,monitor的值由0变为1。当该线程再次遇到monitorenter指令后,就会将monitor继续累加1。这也是synchronized实现重入锁的原理。
我们知道,JVM会有指令重排序的操作。Java会在位置3和位置4之间插入一个获取屏障,在位置18和19之间插入一个释放屏障,这两个屏障保证临界区内的任何操作都不会被指令重排序到临界区之外。加上锁的排他性,临界区内的操作便具有了原子性。
在monitorexit指令后还会插入一个StoreLoad屏障,该屏障保证了monitorenter和monitorexit指令是成对不混乱的,从而保证了synchronized既可并列又可嵌套。
总结:
同步操作的实现,需要给对象关联一个互斥体,这个互斥体就可以叫做锁。
锁的作用是,保证同一竞争资源在同一时刻只会有一个线程占有。
Java中锁的实现方式有两种:synchronized关键字和并发包中的锁类。
锁的优化策略有:锁消除、锁偏向、自适应自旋锁、锁粗化。
尽量不要在循环内使用锁,以减少资源消耗。
3、Java锁的种类
- 公平锁/非公平锁
- 可重入锁
- 独享锁/共享锁
- 互斥锁/读写锁
- 乐观锁/悲观锁
- 分段锁
- 偏向锁/轻量级锁/重量级锁
- 自旋锁
上面是很多锁的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计,下面总结的内容是对每个锁的名词进行一定的解释。
3.1、公平锁/非公平锁
公平锁和非公平锁主要是关于等待的线程的排队的问题,这个排队要利用AQS(AbstractQueuedSynchronizer)。
公平锁 :是指多个线程按照申请锁的顺序来获取锁。
非公平锁 : 是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。
我们以重入锁(ReentrantLock)为例解释AQS。AQS由三个部分组成,
- State:当前线程锁的个数。
- exclusiveOwerThread:当前占有锁的线程 。
- CLH队列等待运行的线程。
- 线程1 CAS算法A=V(state)=0,修改state的值为1
- 线程1又想获取锁,此时A=V(state)=1,state再加1,无论A想获得多少次,只是state+1
- 线程2 进行CAS比较,发现A不等于V,并且发现state不等于0,直接到CLH列队中等待。
- 线程3和线程4也一样到CLH队列中等待。如果先来的线程先排队,获取锁的优先权,则为公平锁。如果,无视等待队列,直接尝试获取锁。
队列如果已经满了,该怎么办呢?
无法进入队列的线程,进入ArrayBlockingQueue,等队列有空位再进入队列。
3.2、可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。对于Java ReentrantLock而言, 其名字是Reentrant Lock即是重新进入锁。对于synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB();
}
synchronized void setB() throws Exception{
Thread.sleep(1000);
}
上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB可能不会被当前线程执行,可能造成死锁。
3.2、独享锁/共享锁
独享锁:是指该锁一次只能被一个线程所持有;
共享锁:是指该锁可被多个线程所持有。
对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写、写读 、写写的过程是互斥的。独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
对于synchronized而言,当然是独享锁。
3.3、互斥锁/读写锁
上面说到的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁 就是独享锁/共享锁具体的实现。
互斥锁:在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作。 加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。
互斥锁在Java中的具体实现就是ReentrantLock;
读写锁在Java中的具体实现就是ReadWriteLock。
3.4、乐观锁/悲观锁
乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
- 悲观锁 :认为自己在使用数据的时候,一定有别的线程来修改数据,在获取数据的时候会先加锁,确保数据不会被别的线程修改。
锁实现:关键字synchronized、接口Lock的实现类。
使用的场景:写操作较多,先加锁可以保证写操作是数据正确。
- 乐观锁:认为自己在使用数据的时候不会有其他的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。
锁实现:java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS(Compare and Swap 比较并交换)实现的。
使用场景:读操作较多,不加锁的特点能够使其读操作的性能大幅提升
3.5、分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。当需要put元素的时候,并不是对整个HashMap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。但是,在统计size的时候,可就是获取HashMap全局信息的时候,就需要获取所有的分段锁才能统计。
分段锁的设计目的是 细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
3.6、偏向锁/轻量级锁/重量级锁
这三种锁是指锁的状态,并且是针对synchronized。在Java 5通过引入锁升级的机制来实现高效synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
偏向锁 :是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
轻量级锁 :是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁 :是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
3.7、自旋锁
是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断判断是否能够被成功获取,自旋知道获取到锁才会退出循环。自旋是通过CAS算法进行的。何为CAS算法呢?
CAS(compare and swap):比较和交换,顾名思义就是先进行比较然后在进行交换。这里比较和交换是线程中的数据和内存中的数据之间的操作。
如下图所示:
- 线程1和线程2都读取内存中的数据V赋值给A
- 线程1把V的值由0改为了1,并想把修改后的值写回到内存
- 线程1将A的值和V的值进行比较
- 两者相等,说明没有线程对V的值进行修改,直接把修改后的值(B=1)写入内存,此时,V=1。
- 线程2进行将A的值和V的值进行比较
- 两者不相等,说明有线程对V的值进行修改,此时线程2不能够把修改后的值写入内存,因为它获得的A的值不是最新的,由A得到的B的值也可能是错误的。线程2会读取A的值,重新计算出B的值,再尝试重新写入,如果还是不相等在继续尝试,不断的自旋。
我们发现CAS算法存在一个非常明显的缺陷,那就是ABA问题。何为ABA问题呢?
如下图所示:线程1线程2 线程3 都获取A=V=0
- 线程1修改V的值为1 写入内存
- 线程2 把v的值改为2,但是没来的及写入,线程3 就开始运行
- 线程3 将V的值改为0 写入内存
- 线程2 比较A和V的值发现A=V,他自认为没有其他的线程对V进行修改,因而忽略了A->B->A的过程,形成了ABA问题。ABA的问题解决方法很简单:AtomicStampedReference在变量前面添加版本号,每次变量更新的时候都把版本号加一。
我们知道自旋是会消耗CPU资源的(不断循环),为什么不用阻塞的方式使等待的线程停止工作呢?原因有两个
(1)同步代码块逻辑简单情况下,自旋消耗的资源时很少的(在同步代码块逻辑简单的情况下,用自旋是比较合适的)
(2)最为关键的原因是阻塞与唤醒线程需要操作系统切换CPU状态,需要消耗一定的时间(CPU上下文切换)
那么为什么阻塞和唤醒线程会消耗大量时间呢?
因为线程的阻塞唤醒涉及大量的步骤,我们以线程阻塞为例进行说明
步骤1:线程1获取CPU时间片执行
步骤2:线程1被阻塞,上下文切换
步骤3:线程2抢占CPU时间片执行
步骤2涉及两个子步骤:
步骤2.1 从用户态切换到内核态
为什么要进行这种切换呢?
用户是没有权限对内存进行操作,我们要切换到内核态才有权对内存进行操作。
步骤2.2 把线程1的状态保存到PCB(内存)
我们到底要保存线程的哪些信息呢?
一个线程在运行时的内存模型主要有5个部分组成:
(1)程序计数器:记录下一条指令的地址
(2)虚拟机栈:保存函数的信息,例如,局部的变量,函数返回地址,操作数等
(3)本地方法栈:和虚拟机栈类似,不过其保存的函数的信息是native函数
(4)方法区:保存类的信息,静态变量等
(5)堆:实例化的对象(堆是用户申请的(C语言中malloc函数),而栈是系统自动分配的)。很明显:程序计数器、虚拟机栈、方法区、堆等信息都会被保存到内存中。
可见上下文切换是非常耗时的。
4、锁的使用
4.1、预备知识
1、AQS
AbstractQueuedSynchronized 抽象的队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的 ReentrantLock、Semaphore、CountDownLatch 。。。
AQS维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。state的访问方式有三种:
getState()
setState()
compareAndSetState()
AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
2.CAS
CAS(Compare and Swap 比较并交换)是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
CAS操作中包含三个操作数——需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B,否则处理器不做任何操作。无论哪种情况,它都会在CAS 指令之前返回该位置的值(在CAS的一些特殊情况下将仅返回CAS是否成功,而不提取当前值)。CAS有效地说明了“ 我认为位置V应该包含值A;如果包含该值,则将 B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可”。这其实和乐观锁的冲突检查 + 数据更新的原理是一样的。
JAVA对CAS的支持:
在JDK1.5中新增java.util.concurrent包就是建立在CAS之上的。相对于对于synchronized 这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以java.util.concurrent在性能上有了很大的提升。
以java.util.concurrent包中的AtomicInteger为例,看一下在不使用锁的情况下是如何保证线程安全的。主要理解 getAndIncrement方法,该方法的作用相当于 ++i 操作。
public class AtomicInteger extends Number implements java.io.Serializable {
private volatile int value;
public final int get() {
return value;
}
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
}