JUC笔记
管程:锁对象
守护线程:为其他线程服务的后台线程
JMM
该部分为 《深入理解java虚拟机》第二版 中关于java内存模型的描述
为了屏蔽各个平台的内存差异,jvm创建了java内存模型。其主要目标是定义程序中各个变量(线程共享变量)向内存读写的规则
JMM规定所有的变量都存储在主内存中,每个线程都有自己的工作内存,线程所使用的变量都是主内存到工作内存的拷贝。但它们和堆栈内存是两个概念。
主内存和工作内存间的交互通过8个java原语实现,例如如果要将变量从主内存拷贝到工作内存,则需要顺序执行 read、load 两个原语,反之,则顺序执行 store、write。
volatile
-
可见性
-
禁止指令重排序
线程读取被volatile修饰的变量会强制从主内存刷新工作线程的值,修改也将立即向主内存同步,故而保证其对所有线程的可见性
但需要注意的是可见性并不一定线程安全,因为对变量的操作并非原子操作
除了volatile外,synchronized 和 final 也能保证可见性,同步代码块的可见性是因为:变量在执行unlock之前,必须同步回主内存中。所谓可见性,实际上指的是修改后的可见性,final修饰的变量在分配内存阶段就已经赋值了,且都不允许被修改,天然保证了可见性
所谓指令重排序,当两行代码的执行没有依赖关系时,可能在执行时顺序被交换,例如:
doInitialize()
intialized = true
实际执行时,cpu并不知道两者存在逻辑关系,所以可能将 intialized=true 提前执行,如果其他线程要根据该变量做一些判断,则可能出现问题
其实现方法是,创建一个内存屏障,内存屏障之后的代码不会被重排序到之前执行
synchronized也能解决指令重排序带来的问题,但它并不是防止指令重排序。指令重排序带来的问题本质上是多线程下才会出现的问题,而synchronized能保证在加锁状态下,变量只能被一个线程访问
happend-before
先行发生原则描述的是,在几种特定的场景下,代码会有明确的先后执行顺序。例如:线程的start()方法一定会比该线程的其他方法先发生,在同一个线程中对某变量的操作,书写在前面的代码总是比后面的代码先发生。这些原则有什么用呢?
它们就好比是数学中的一些基本假设或基本条件,由这些基本条件可以推导出很多复杂的结论。
多线程环境下,可以由这些原则判断程序代码是否安全。
实现线程安全的方法
互斥同步
同步指多线程在访问某资源时同一时刻只能有一个线程来访问,互斥是实现同步的一种手段
synchronized是实现同步的一种手段,同时它也是一种可重入锁,即如果是当前线程已经获取了锁对象,则它下次仍然可以进入被锁的代码块中。与之类似的还有juc中的ReentrantLock,它们都是可重入锁,只是使用的语法上有所区别,ReentrantLock使用lock()和unlock()两个api实现加锁和释放锁,而synchronized使用字节码指令monitorenter和monitorexit来加锁和释放锁。另一个区别为,RenntrantLock实现了更多高级的功能,例如:
-
等待可中断:可以为线程设置等待超时时间
-
公平锁:多个线程按照先来后到顺序获得锁
-
Condition:Object的wait和notify的另一种实现,可以实现对其他线程的独立控制
jvm在后续改进中,synchronized的性能和ReentrantLock的性能也差不多
非阻塞同步
互斥同步本质上是悲观锁,代码进入同步区域会首先加锁,而非阻塞同步则是乐观锁,即先进行操作,如果没有其他线程争用共享数据,则操作成功,如果产生了冲突,则采取其他的补救措施
乐观锁减少的是在冲突较少的场景下,加锁解锁的开销,而在冲突较多的场景下,乐观锁比悲观锁性能更差
CAS是乐观锁的一种实现,它有三个属性:目标值地址,旧的目标值,新的目标值。只有当旧的目标值和地址中的值相等时,才会将地址中的值修改为新的目标值。但可能出现ABA问题,JUC中使用变量版本号来解决该问题。不过一般来说,这种问题并不会对程序造成影响。
无同步
如果没有共享数据,那就不用考虑同步问题。例如纯函数,或者栈上分配技术,或者ThreadLocal数据等
锁优化
自旋锁
使用忙循环来代替线程阻塞唤醒的内核态开销
但自旋只适用于短时间的共享资源占用,jdk6引入自适应自旋,当上次自旋成功获得锁后,则认为这次也可能成功获得锁而自旋,因此运行自旋更长时间,但如果很少有自旋成功的,则自动省略自旋进入阻塞
锁消除
如果经过逃逸分析发现共享变量不会被其他线程共享操作,则将锁消除
锁粗化
如果一段代码内频繁地加锁解锁,则可能优化为对整段代码一次性加锁
轻量级锁
对象内存布局为:对象头+数据部分+对齐填充
而对象头又分为:markword + 指向方法区Class的指针(数组的话还需要加上数组的长度)
markword在32位机下长度为32位(64位机下为64位),其中25位存储对象hash值,4位存储GC年龄,2位存储锁标志位,1位表示偏向锁是否可用。其中前25+4+1=30位内容会随着锁标志位的变化而变化,具体为:
锁标志位 | 前29位存储内容 | 对象状态 |
---|---|---|
01 | 对象hash+GC年龄 | 未锁定 |
00 | 指向栈帧中 锁记录 对象的指针 | 轻量级锁 |
10 | 指向Monitor(管程)对象的指针 | 重量级锁 |
01 | 偏向线程ID、偏向时间戳、GC年龄 | 可偏向 |
11 | null | GC标记 |
轻量级锁加锁过程:
-
如果当前执行某方法遇到加锁指令,则会在当前方法的栈帧中创建一个 锁记录 对象,并使用CAS将锁对象markword中的前29位存储内容复制到锁记录对象中,然后将锁对象的markword前29位修改位指向该 锁记录 对象的指针,并将锁标志位修改位00。
-
如果当前线程又需要使用该对象锁时,发现其已经处于轻量级锁状态,则先判断其markword中的指针是否指向当前线程中栈帧的锁记录,如果是,则可直接进入同步代码块。如果当前轻量级锁已经升级为了重量级锁(管程,见下文),则直接判断管程中的owner属性是否等于当前线程id,如果是则可进入。也即,可重入锁
-
如果是另一个线程尝试获取锁,则当它发现该锁对象处于轻量级锁状态时,就会将其修改位 10 重量级锁,并将对象头中前30位修改位指向 monitor (管程或重量级锁)的指针
注:
-
monitor(管程)是jvm中表示锁的一个对象,其内部有一个阻塞队列。一个monitor同一时刻只能被一个对象持有,其他线程如果想获得该对象锁就会被加入到 monitor 的阻塞队列中。
-
可重入锁都会记录重入次数,每次持有锁的线程进入一次就会加1,离开时减1,即加锁几次就要解锁几次,例如ReentrantLock
偏向锁
偏向锁和轻量级锁类似,区别在于,偏向锁直接在markword中记录持有锁的线程id,以避免同一个线程再次加锁时还需要去判断栈帧中的锁记录地址是否与markword中的相同,直接判断线程id就好了。
偏向锁和未锁定的锁标志都是01,它们根据markword中的那1位(表示偏向锁是否可用)来区分,1表示可用
如果多线程下发生冲突,则会将锁改为轻量级锁或者重量级锁
偏向锁不也是需要判断吗?它简化了什么流程?
偏向锁直接判断对象头中的markword信息,不需要切换到内核态做判断,提升了性能
注:由于markword的容量有限,由于偏向锁需要在markword中记录线程id,所以,如果一个对象的markword中已经保存了hashcode时,它就不能使用偏向锁,因为已经没有地方存线程id了。
如果一个对象已经加了偏向锁,此时又去调用hashcode()方法(调用该方法会将hashcode保存到markword),那么偏向锁就会升级为轻量级锁或者重量级锁
由于使用hashcode场景较为常见,所以偏向锁反而使得性能降低,故在jdk14后逐步移除偏向锁
创建线程
创建线程有几种方式?
-
继承Thread类
-
实现Runable接口
-
实现Callable接口
-
使用线程池
Runable和Callable接口的区别?
主要区别在于Callable有返回值和异常,而Runable就是一个后台任务
# Callable用法
public static void main(String[] args) {
Callable callable = new Callable() {
@Override
public Object call() throws Exception {
Thread.sleep(3000);
return "abc";
}
};
Object res = null;
try {
res = callable.call(); // 阻塞至子线程得到结果或抛异常
System.out.println(res);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
什么是Future接口?,FutureTask和CompletableFuture又是什么?
Callable和Runable本质上都是将任务放到后台执行,但也仅是放到后台执行,它并没有提供什么方法来获取线程的状态。对于Callable来说,它也只提供了一个call的方法获取返回值,但该方法仍然是阻塞的
而Future接口则实现了更多对Callable和Runable创建的线程监控的方法
FutureTask是Future的实现类,它的构造器支持传入Callable和Runable实例
可以说,Future接口是对Callable和Runable接口方法的扩展
但是,Future扩展的方法仍然不够,虽然可以调用isDone方法判断子线程任务是否完成,但需要循环判断(如果判断没完成,等待一段时间再次判断),这样仍然不够高效。所以又出现了CompletableFuture,它是Future的一个实现类,并且扩展了更多实用的方法,比如说,它支持传入一个回调函数,当子线程任务执行完成后自动调用该回调函数等等。
线程中断
线程中断并不是让线程停止,相反,它是让不活动的线程活动起来,对活动的线程是无效的
例如创建了一个子线程,并且让子线程处于wait或block状态,此时子线程就处于不活跃状态,如果此时在主线程调用 subThread.interrupt() 方法就会让子线程结束睡眠或阻塞。这也是为什么 sleep() 方法会抛出 InterruptedException 的原因。
而如果子线程处于运行状态,在主线程调用 子线程的 interrupt() 方法,则不会对子线程造成任何影响,但会对下次sleep造成影响。
事实上,线程对象中存有一个 volatile boolean interrupted
的属性,当调用 thread.interrupt() 时会将其置为true,并尝试中断目标线程(中断休眠,唤醒目标线程),中断成功后将该值置为 false。但是,如果线程本身就处于活动状态,则不会重置该属性状态,故而下次当线程执行sleep()方法时就会发现该值为true,直接结束休眠,然后重置该属性。
对于活动的线程其实也可以处理中断请求,就是在代码中循环判断该属性值(提供有专门的获取方法 isInterrupted),也算是线程通信的一种方式。
synchronized
以下来自:https://www.bilibili.com/video/BV1ar4y1x727
实例方法上加synchronized使用当前对象作为锁
类方法上加它使用当前对象的Class对象作为锁
同步代码块可以指定使用什么对象作为锁
字节码加锁原理
在同步代码块中,该关键字在字节码层面使用monitorenter和monitorexit作为锁开始和结束标志
在方法申明上,其通过在方法标志位加 ACC_SYNCHRONIZED 关键字进行加锁,实例方法和静态方法通过 ACC_STATIC 标志进行标识
底层加锁原理
偏向锁-轻量级锁-重量级锁
ThreadLocal
理解:出现线程安全问题的本质是多线程共享同一个资源,从JMM角度来说,该资源处于主内存中。如果创建的资源在工作内存中,因为只有一个线程可以访问,则不会有安全问题,ThreadLocal变量的声明方式和普通的变量声明方式是一样的,只不过被ThreadLocal包装的对象会自动创建在每一个线程的工作内存中,而普通变量则创建在主内存中:
# 需要多少个线程独享的变量就创建多少个ThreadLocal
ThreadLocal<Integer> tl = ThreadLocal.withInitial(() -> 0);
Integer num = 0
# 线程1的代码
tl.set(1) # 此时在线程1任意位置调用 tl.get() 得到的都是1
num = 1 # 此时在线程1任意位置获取 num 的值不一定,因为可能被线程2修改
# 线程2的代码
tl.set(2) # 此时在线程1任意位置调用 tl.get() 得到的都是2
num = 2 # 此时在线程1任意位置获取 num 的值不一定,因为可能被线程1修改
ThreadLocalMap
ThreadLocalMap可以理解为是一个Map对象用于存储KV键值对
每个线程内部都有这么一个对象,当线程创建了ThreadLocal对象时,就会以该ThreadLocal对象作为key,ThreadLocal对象包裹的值作为value
简单来说,当创建了一个多线程共享的ThreadLocal对象时,本质上是在每个线程内部创建了一个KV键值对,K就是该ThreadLocal对象,V就是其内部存放的值(容易搞混的就是这里,ThreadLocal对象既是K,又是V)。这个键值对存储在线程内部的ThreadLocalMap属性中。
注:ThreadLocalMap其实是ThreadLocal的内部静态类。但理解上,为了简单,就单独理解成一个Map类即可
为什么使用弱引用?
当我创建一个ThreadLocal对象时,线程内部会自动创建一个KY键值对,即一个新的 Entry(k, v) 对象,需要注意的是,只有 Entry 中的k是弱引用,而这个k正是ThreadLocal对象。
需要明确的一点是,线程中的ThreadLocal和ThreadLocalMap中的k(也即ThreadLocal)只是堆中ThreadLocal对象的引用。
正常情况下,因为我还在线程中使用ThreadLocal对象,所以它是一个强引用,指向堆中的对象,自然Entry中的K(即ThreadLocal对象不会被GC),但是如果我此时将ThreadLocal对象置为null,则只剩下ThreadLocalMap中的k指向堆中的ThreadLocal对象,此时就已经发生了内存泄漏了。因为你已经将线程中的ThreadLocal置为null了,你想删除ThreadLocalMap中的k都删除不掉了。
好在jdk考虑到了这一点:
-
将ThreadLocalMap中的定义为弱引用,这样一来,如果堆中ThreadLocal对象只有这一个弱引用,那么它其实也会在下一次GC时被清除
-
此时,ThreadLocalMap中被回收的K已经都为null了,但是value仍然存在,如果不去清理k为null的Entry,仍然会发生内存泄漏
-
所以线程在每次操作ThreadLocalMap都会自动检查所有k为null的Entry,然后自动清除
为什么Entry的value不使用弱引用?
如果value也使用弱引用的话,你的k(threadLocal)还没删除呢,value就在下一次GC时置为null了。
关于弱引用的理解可以看下面这两段代码:
# 此时堆中的 String 对象只被一个弱引用所引用,所以活不过下一次GC
WeakReference<String> weakReference = new WeakReference<>(new String("bingo"));
System.out.println(weakReference.get()); // bingo
System.gc();
System.out.println(weakReference.get()); // null
# 此时堆中的 String 对象不仅被弱引用所引用,而且还被局部变量表中的s所引用(强引用),所以不会被GC
String s = new String("bingo");
WeakReference<String> weakReference = new WeakReference<>(s);
System.out.println(weakReference.get()); // bingo
System.gc();
System.out.println(weakReference.get()); // bingo
AQS
JUC提供了很多实现同步的方法工具,如果说JVM是java的基础,则AQS(AbstractQueuedSynchronizer)就是JUC的基础
JUC提供了例如:CountDownLatch、ReentrantLock、Semaphore、CyclicBarrier、ReentrantReadWriteLock 等一系列API,它们的底层基础都是AQS
AQS简单来说分为两个部分:
-
锁状态信号
-
线程等待队列
锁状态信号就是一个int型的数字,用于表示该锁是否被线程占有。线程等待队列则是当锁被线程占有时,其他等待的线程队列
所有等待队列中的线程都会被封装成一个Node对象,每个Node对象都有一个state状态标志,这和AQS自己的state状态标志不同,Node的状态标志用来标记线程状态时等待还是取消(早期的jdk有更多标志,例如-1表示该节点后续还有节点排队)
例如ReentrantLock底层就是使用AQS实现的,故下面以它为例对AQS的功能和原理进行解释:
ReentrantLock
注:教程视频中使用的AQS源码和我在本地看到源码有较大出入,可能是版本不同。
基本用法为:
// 构造器传入一个bool值,用于表示是否创建公平锁
ReentrantLock lock = new ReentrantLock(false);
// 加锁
lock.lock()
// 资源访问 ...
// 解锁
lock.unlock()
上述代码创建了一个非公平锁,通过查看源码得知,ReentrantLock内部维护一个sync对象,该对象是AQS的一种实现,而调用的lock()和unlock()方法本质上是调用sync的lock()和unlock()方法,即AQS的方法。
调用lock()方法时,首先使用cas机制根据锁状态标志获取锁(非公平锁先尝试获取锁),如果获取失败则说明当前锁被占用,则继续判断是否是当前线程占有锁,如果不是则调用acquire()方法,该方法是一个死循环,当然,内部是会有休眠机制的,如果被唤醒会循环判断当前线程是否满足执行条件,如果不满足则继续休眠等待唤醒,这就是死循环的作用
acquire()方法会首先判断当前Node是否为第一个节点,如果是,则尝试获取锁,否则:
如果队列为空,则初始化队列,方法为创建一个空节点作为队头节点,并将其节点的state设置为0,并开始新一轮循环
如果节点还没入队,则入队并将节点的state设置为1(waiting状态),继续循环
最后将节点线程调用 LockSupport.park() 方法进行休眠等待唤醒
注:经常在这个方法里可以看到 Thread._onSpinWait_()
方法调用,该方式其实类似于 yield 方法,用于让出当前cpu执行权限,但会竞争下一次执行权限。与之类似的还有 Thread.sleep(0) 这种写法
关于 yield 和 onSpinWait 的区别:
onSpinWait性能比yield更好,yield是告诉cpu让出当前事件片段,但继续竞争后续的时间片段,而onSpinWait则是告诉cpu,接下来会有一段忙循环代码,所以cpu会更少调度该线程,会更少执行该线程的指令,或者简单理解为会暂时降低该线程的优先级。
https://stackoverflow.com/questions/56056711/threadyield-vs-threadonspinwait
https://ionutbalosin.com/2018/06/onspinwait-method-from-thread-class/
ReentrantReadWriteLock
ReentrantLock功能类似于synchronized,都是排它锁,但很多时候,如果多线程只是读取共享资源其实并不需要排他,所以有了读写锁ReentrantReadWriteLock。它们两者实现的是不同的接口,但底层都是使用AQS实现加解锁功能
但它有两个问题:
1. 写饥饿
假设这样一个场景,如果某资源读取一次需要较长时间,有一个读线程加上了读锁正在读取数据,同时,有一个写线程被阻塞。此时,读锁还没释放就有源源不断有新的读线程去读取数据,由于读锁不互斥,所以这些新来的读线程都能成功加上读锁,那么,这个资源的读锁将一直不被释放,写线程将一直获取不到锁。
2. 锁降级
首先需要明确两点:
-
线程已经获取了写锁后,仍然可以再获得读锁
-
锁降级是一个手动的过程,并不是向synchronized锁升级一样是自动的过程。
考虑这样一个场景:多个线程希望对某个共享资源做修改,另一个线程用来记录该资源每次的改动,规定:每一次的修改都必须被记录。这里最关键的问题是:如果某一个线程对资源做了修改,记录线程还没记录,又有一条修改线程对资源做了修改,则记录线程就丢失了一次记录。如何解决这个问题?
当然,使用事件驱动的方式或者等待唤醒机制很容易实现这个功能,但如何只使用锁来实现呢?
使用排它锁是解决不了的,因为多线程竞争的情况下,有可能连续两次都是修改线程得到锁。
单纯使用读写锁也是解决不了的,同样是因为多线程竞争的情况下,可能连续两次都是写锁。
一个解决方法是:当修改线程获得写锁并修改资源后,先再获得一个读锁,然后再释放写锁,此时,资源仍持有读锁,其他修改线程是进不来的,因为读锁不互斥,此时记录线程仍然可以获得读锁,记录线程获得读锁后,之前的线程再释放读锁。如此一来,就能保证记录线程可以记录每一次的修改。
这一过程从外面来看就像是写锁结束后直接降级成为了读锁一样,其实读锁还是单独手动加的。
StampedLock
在读写锁的基础上,增加了一个乐观读锁。
如果说读写锁ReentrantReadWrite是悲观锁,则StampedLock中的乐观读锁则是乐观锁。其写锁会无视乐观读锁,直接修改数据,如果出现了一个乐观读锁内读取数据不一致的情况(加上了乐观读锁后,数据仍然被修改),则由程序员自行解决。
其基本思路为:为共享资源数据创建一个版本号,写锁的加解锁不变,只不过加写锁时不用考虑是否有乐观读锁了。而加乐观读锁时,会返回当前数据的版本号,程序在读资源过程中可以随时调用 validate(stamp) 来判断数据是否被修改(数据版本号和加锁时是否相同),如果数据被修改了,则可以选择使用排他读锁,或者重新使用乐观读锁。
注:由于版本号的存在,该锁不可重入
以下为示例代码:
StampedLock stampedLock = new StampedLock();
// stamp 就是数据的版本号
long stamp = stampedLock.tryOptimisticRead();
// read ...
if (stampedLock.validate(stamp)) {
// 数据没有被修改,释放乐观读锁
stampedLock.unlock(stamp);
} else {
// 数据被修改了,重新读取数据或者使用排它锁
stampedLock.unlock(stamp);
stamp = stampedLock.readLock();
// 重新读取数据 ...
stampedLock.unlock(stamp);
}