前言
Java并发编程是复杂而又重要的知识点,最近在学习《Java并发编程之美》,这篇文章就权当作笔记吧~
一切锁的根源:CAS
[1]
CAS 即 compareAndSwap,这里并不是特指java.unsafe包中提供的CAS方法,而是更广义的,经典的乐观锁实现:CAS;
ABA问题
CAS的定义等这里就不再赘述,只讨论一下经典的ABA问题:
对于一个变量X,线程t1读取到他的值是A,于是进行compare and swap;compare之后发现值确实是A,于是将其swap成B;
看起来一切顺利,但是很有可能在这个线程首次读取到X的值和进行compare之间,另一个线程t2将这个变量的值更改成了B后又改回了A;
造成的影响就是t1以为自己在独占这个变量,实际上并没有~
解决方法:
前面提到了Java的Unsafe包中有一系列对于CAS的实现;Java对于ABA问题的解决方法就是:给每个变量的状态值都配备了一个时间戳,这样即使线程T2将X的值又改回了A,但是时间戳已经发生了变化,A已经不再是原来的A了,T1自然就会cas失败。
此外CAS还有 循环时间长开销大、只能保证一个共享变量的原子操作 等问题,这里不再展开描述。
经典的锁:synchronized
推荐一下java guide中对于synchronized的介绍,下面仅挑选我感兴趣的部分复述总结一下。
锁升级
在JDK1.6中对synchronized进行爆改后,现在的Synchronized锁有四种状态,依次为:无锁、偏向锁、轻量级锁和重量级锁;他们会随着竞争的激烈而逐渐升级。
对象头
经典面试题: 当synchronized加在普通方法上时,锁的是对象;当加在静态方法上时,所得就是整个类。可以侧面的看出synchronized的锁操作与对象有着很紧密的联系。
在 HotSpot 虚拟机中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐填充。
对象头中包含两部分:MarkWord 和 类型指针。[2]
多线程下 synchronized 的加锁就是对同一个对象的对象头中的 MarkWord 中的变量进行CAS操作。
下面我们使用32位JVM进行举例说明[3]
32bit | 32bit | 32bit |
---|---|---|
MarkWord | 类型指针 | 数组长度 |
无锁状态
25bit | 4bit | 1bit | 2bit |
---|---|---|---|
对象的HashCode | 对象分代年龄 | 是否为偏向锁(0) | 锁标志位(00[4]) |
这里给出的只是hotspot某一版本的实现,可能和最新的版本有所出入,但是大意和设计思想相同
偏向锁
23bit | 2bit | 4bit | 1bit | 2bit |
---|---|---|---|---|
线程ID | epoch | 对象分代年龄 | 1 | 01 |
偏向锁是为了在对象只有一个线程访问的情况下提供性能优化的机制,不会有解锁操作(只有撤销);获取锁的操作就是尝试将自己的线程ID CAS进markword的开头23bit中[5]。
如果偏向锁出现了竞争,锁会被撤销;锁会退回到偏向锁但无线程获取到锁的状态(线程ID置0);此时如果还是有多线程进行竞争,偏向锁就会升级。
轻量级锁
30bit | 2bit |
---|---|
指向线程栈锁记录的指针 | 10 |
之所以叫做轻量级锁,是因为他仅仅使用CAS实现获取锁。相对于稍后讲到的重量级锁要快很多
当线程尝试获取一个轻量级锁的时候,线程会在自己的栈帧中创建一个锁记录(Lock Record),记录下对象的地址和线程的ID(其实就是偏向锁时的对象头markword内容);随后使用CAS将对象头的mark word更新为指向自己锁记录的指针,如果成功代表该线程获取到了锁。如果失败线程会进入自旋。
当等待线程自旋次数达到一个阈值时,轻量级锁会升级成重量级锁。
重量级锁
30bit | 2bit |
---|---|
指向锁监视器的指针 | 11 |
重量级锁是依赖操作系统互斥量(mutex)来实现的传统锁。他与轻量级锁的区别就是:获取不到锁的线程不再自旋,而是直接进入阻塞状态。
好处是阻塞线程不会因为自旋而消耗CPU;在竞争激烈的情况下代表有许多线程在进行自旋,对CPU的占用还是很可观的。
问题是在线程状切换时会有系统调用,有一定的开销;但是相对于大量线程自旋,这个开销可以接受。
而在JDK1.6推出之前,synchronized没有升级的过程,上来就直接是重量级锁,所以性能比较差~
高级锁的底层支持——AQS
AbstractQueuedSynchronizer 抽象同步队列简称AQS;这是实现java中各种复杂同步器[6]的基础组件。神奇的是他的许多实现类中甚至没有依赖synchronized关键字,而多是使用Unsafe包中提供的CAS操作。
AQS毕竟是一个抽象类,他的抽象程度是比较高的;按我个人的理解,其中有两块比较大的抽象。
首先是对与锁内的抽象,锁一定是锁住了一个资源;AQS将这个资源抽象成了两种类型:独占资源与共享资源。顾名思义,独占资源即同时只支持一个线程进行操作的资源;共享资源同时支持多个线程进行操作的资源,且这些线程之间可以共享。
然后是对锁的使用者的抽象,锁的使用者可以分为两类:获取到锁的线程与未获取到锁的线程,获取到锁的线程使用锁的状态来进行管理;未获取到锁的线程使用一个(或几个)队列进行管理。每个队列对应着一个condition,队列中的线程等待着condition被signal。
事实上AQS很少会被用户实现,因为JDK已经提供了一套很灵活的锁供开发者使用,下面选取几个比较经典的进行介绍。
最近在看ThreadPoolExecutor中Worker的实现,每个Worker会在线程池中掌管一个线程,他就实现了AQS,并通过重写其
tryAcquire
,tryRelease
,isHeldExclusively
等方法实现了一个不可重入排他锁,用于标记线程的状态; 对于理解AQS十分有帮助。
独占锁ReentrantLock
这是使用AQS实现的一个可重入独占锁,和同样相当于独占锁的synchronized关键字相比具有更多的灵活性~ 下面就介绍一下这个ReentrantLock锁独有的特性。
可中断性
没有获取到synchronized同步块锁从而进入阻塞状态的线程是不会响应中断的;但是ReentrantLock提供了可以响应中断的获取锁的方法lockInterruptibly()
;
这里解释一下原理:
java中的中断是一种约定性的协同机制。靠Thread中的一个boolean标记位实现(native实现),调用interrupt()方法仅仅是将这个标志位置为true;至于线程是否处理这个标记位,如何处理都又线程自主决定。
而在wait(),sleep(),join()等可以造成阻塞的方法中对于这个标记进行了处理(抛出InterruptedException),所以这些阻塞是可以被中断的;ReentrantLock在线程的自旋等待逻辑中同样对中断标记进行了检查和响应。
而synchronized自然是没有理会这个标记位,自然在等待过程中无法响应中断。
公平性
ReentrantLock可以在实例化时指定使用公平锁还是非公平锁;公平锁会按线程请求锁的顺序提供资源,而非公平锁则不是。毕竟是基于有专门的队列来维护等待线程集合的AQS,这各特性不难实现。
至于synchronized,他只能是非公平锁。
灵活性
ReentrantLock提供了多个条件变量(Condition),可以在不同的条件下等待和唤醒线程。这使得开发者可以更加灵活地控制线程的等待和唤醒。
synchronized关键字只能支持一个条件队列,限制了线程间的协作方式。
快速失败获取锁
ReentrantLock提供了tryLock()
方法,如果当前的锁被其他线程持有就会立即返回失败,而不是像synchronized一直等待。
可以看出ReentrantLock比Synchronized要灵活许多;但是使用时也更容易造成死锁等问题,所以使用的时候要认真考虑场景与需求,确保使用的正确性与安全性~
无聊的ReentrantReadWriteLock
其实这个锁我是不想介绍的,因为他简单又无聊;
但是我隔了不到一周再次看到这个名字时竟然还是不能一下子说出这个锁的特性……
所以这里简单记录一下:
与reentrantLock的区别为: 这个读写可重入锁他不是一个独占锁;
- 他本身包含了读锁和写锁,其中读锁可以多个线程共同获取,写锁只能一个线程获取;
- 当有线程获得读锁或写锁时,其他线程无法再获得写锁
- 当有没有线程获得写锁时,任意线程都可以获得读锁
- 读锁不能升级成写锁;但是写锁可以降级成读锁
- 适用于读多写少的情况
以上,确实非常符合直觉~ 如果我有一天看到题目就想起了下面的东西,记得把文章的这一部分删掉(
偏题一下:CountDownLatch
这是一个在书中没有详细介绍的类[7],但我感觉他蛮特殊的;所以查了一些资料这里整理一下。
CountDownLatch并不是一个锁,他是一种不同于锁的同步辅助类,多是用于控制多个线程的执行顺序;
CountDownLatch
主要有两个方法:countDown()
和await()
,当然还有接受一个计数器初始值作为参数的构造方法;
使用场景就是一个线程A的工作需要等待其他多个线程B、C、D完成之后再开始;那么可以这么操作:
- 在A中
CountDownLatch latch = new CountDownLatch(3)
; - 将这个latch传入BCD三个线程中;
- A在开始工作前调用
latch.await()
- 在BCD三个线程中添加一个完成任务后调用
latch.countDown()
的逻辑
这样就可以实现BCD完成工作之后A再开始工作啦~
类似功能的还有CyclicBarrier
, Semaphore
等工具类,他们也有各自的特点;但是这里不会详细介绍,因为实现这种逻辑的利器其实是——CompletableFuture
~
以上就是我对《java并发编程之美》这本书的前半关于锁的部分的总结;实际上这些知识的实战意义可能并不大(笑),但是了解了解还是有点意思的~ 系统的梳理一下也可以加深自己的印象和理解!
此外博客已经有一段时间没有进行更新了,这里希望把这个东西再捡起来;tomcat网络处理的文章已经在草稿箱啦! 后面可能还会更一篇关于线程池的文章~(flag+2)