线程安全
java中的各种锁
下图为java中各种锁的分类情况:
图片来源:https://tech.meituan.com/2018/11/15/java-lock.html
下表为各种锁的应用关键字
表格来源:https://www.cnblogs.com/lanqingzhou/p/13723584.html
序号 | 锁名称 | 应用 |
---|---|---|
1 | 乐观锁 | CAS |
2 | 悲观锁 | synchronized、vector、hashtable |
3 | 自旋锁 | CAS |
4 | 可重入锁 | synchronized、Reentrantlock、Lock |
5 | 读写锁 | ReentrantReadWriteLock,CopyOnWriteArrayList、CopyOnWriteArraySet |
6 | 公平锁 | Reentrantlock(true) |
7 | 非公平锁 | synchronized、reentrantlock(false) |
8 | 共享锁 | ReentrantReadWriteLock中读锁 |
9 | 独占锁 | synchronized、vector、hashtable、ReentrantReadWriteLock中写锁 |
10 | 重量级锁 | synchronized |
11 | 轻量级锁 | 锁优化技术 |
12 | 偏向锁 | 锁优化技术 |
13 | 分段锁 | concurrentHashMap |
14 | 互斥锁 | synchronized |
15 | 同步锁 | synchronized |
16 | 死锁 相互请求 | 对方的资源 |
17 | 锁粗化 | 锁优化技术 |
18 | 锁消除 | 锁优化技术 |
synchronized
简单案例
先来看下这个案例,用两个线程对num变量执行2千万次+1,看看结果是否为2千万。
类A:
public class A {
private int num = 0;
public void increase(){
num++;
}
public int getNum(){
return num;
}
}
类LockTest :
public class LockTest {
public static void main(String[] args) throws InterruptedException {
A a = new A();
long start = System.currentTimeMillis();
Thread t1 = new Thread(()->{
for(int i=0;i<10000000;i++){
a.increase();
}
});
t1.start();
for(int i=0; i < 10000000; i++){
a.increase();
}
t1.join();
long end = System.currentTimeMillis();
System.out.println(String.format("%sms", end - start));
System.out.println(a.getNum());
}
}
执行上面代码,结果是否为2千万呢?如下:
这个是线程不安全的典型例子,那么怎么要使得线程安全呢?可以给A#increase()方法加把锁,如下:
// 非静态成员方法上加锁,实际上锁的是方法所属对象
// 静态成员方法加锁,锁的是类.class
public synchronized void increase(){
num++;
}
// 或者:
public void increase(){
synchronized (this){
num++;
}
}
接下来再执行上面的代码,运行结果如下,发现是我们想要的结果
总结:
互斥锁、悲观锁、同步锁、重量级锁。
jdk1.6之前没有优化:线程阻塞、上下文切换,操作系统线程调度,特别耗费资源,耗费时间。
锁优化
jdk1.6之后开始对synchronized底层开始做大量优化。下面是synchronized在实际应用中锁升级的过程图。
- 无状态:即,没有加锁。
- 偏向锁:锁优化技术。当一段代码(一个程序)在绝大数时候只有一个线程执行,偶尔会有多线程同时执行的情况。那么把常执行的这个线程id保存到加锁对象的对象头markword里面(例如,上面案例中,会把线程id保存到this对象的markword里面)。每次只要判断当前线程id和加锁对象的对象头markword里面保存的线程id是否一致。若一致,不加锁直接继续执行;若不一致,则加锁。
- 轻量级锁:一般指的就是cas,锁优化技术。
- 重量级锁:这里是synchronized(没有优化)。
附加:java对象组成
在分析锁升级之前先看看java的对象组成
java对象内部组成如下图:
java对象内部共分为3部分:对象头、实例数据、对齐填充位。对象头:
mark word:包含对象的哈希码,锁的一些相关存储;
元数据指针:类对象存在堆里,.class文件(类文件)存在元数据里。类对象的对象头的元数据指针 指向 元数据里面存储的类文件。
各种锁在对象头的mark word存储占位情况。
锁升级代码演示
先添加个依赖,如下:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.11</version>
</dependency>
(1). 无状态
public static void main(String[] args) throws InterruptedException {
// User类有两个成员变量:id(Integer), name(String)
User userTemp = new User();
System.out.println("无状态(001):" + ClassLayout.parseInstance(userTemp).toPrintable());
}
运行后控制台打印如下(下面的表格是我截图工具人为画出来的):
表格第1行为表头
第2~4行为对象头(Object header);size列单位为Byte;
value列对应单元格内容:括号外为4个16进制,括号里面是对应的4个二进制。
第5,6行为实例数据,类User成员变量id、name的初始值都为null
第7行,即最后一行为对齐填充位:填充了4Byte。
这个对象一共占用空间24字节。为什么后面要再填充法4个字节,凑齐24Byte呢?
因为我们当前所用电脑基本都是64位的,在内存里面排列时每8个字节(64位)划分为一行。为了满足最优寻址,对象所占空间大小能被8整除。这里再填充4个字节刚好凑够24个字节在内存里面刚好占3行(3 = 24 / 8)。寻址快。
无状态的(001)体现在这里,顺序刚好从右到左与上面的64位虚拟机锁的占位情况对应上。
(2). 偏向锁
public static void main(String[] args) throws InterruptedException {
User userTemp = new User();
System.out.println("无状态(001):" + ClassLayout.parseInstance(userTemp).toPrintable());
/*jvm默认延时4s自动开启偏向锁,可通过-XX:BiasedLockingStartupDelay=0 取消延时
如果不要偏向锁,可通过-XX:-UseBiasedLocking = false 来设置*/
Thread.sleep(5000);
User user = new User();
System.out.println("启用偏向锁(101):" + ClassLayout.parseInstance(user).toPrintable());
for (int i = 0; i < 2; i++) {
synchronized (user) {
System.out.println("使用偏向锁(101)(带线程id):" + ClassLayout.parseInstance(user).toPrintable());
}
System.out.println("释放偏向锁(101)(带线程id):" + ClassLayout.parseInstance(user).toPrintable());
}
}
运行后,控制台部分日志如下:
上面截图中红色框为锁的标志位,绿色框为线程id存储的地方(没有保存线程id时全为0)
(3). 轻量级锁
public static void main(String[] args) throws InterruptedException {
User userTemp = new User();
System.out.println("无状态(001):" + ClassLayout.parseInstance(userTemp).toPrintable());
/*jvm默认延时4s自动开启偏向锁,可通过-XX:BiasedLockingStartupDelay=0 取消延时
如果不要偏向锁,可通过-XX:-UseBiasedLocking = false 来设置*/
Thread.sleep(5000);
User user = new User();
System.out.println("启用偏向锁(101):" + ClassLayout.parseInstance(user).toPrintable());
for (int i = 0; i < 2; i++) {
synchronized (user) {
System.out.println("使用偏向锁(101)(带线程id):" + ClassLayout.parseInstance(user).toPrintable());
}
System.out.println("释放偏向锁(101)(带线程id):" + ClassLayout.parseInstance(user).toPrintable());
}
// 创建了个新线程对user对象加锁
new Thread(()->{
synchronized (user){
System.out.println("轻量级锁(00):" + ClassLayout.parseInstance(user).toPrintable());
}
System.out.println("释放轻量级锁(00):" + ClassLayout.parseInstance(user).toPrintable());
}).start();
}
运行后,控制台部分日志如下:
上面截图中红色框为 锁状态位00,其他位保存指向栈帧中记录的指针。
(4). 重量级锁
除了主线程外,新创建个线程(名称:线程1)给user对象加锁,休眠3秒,此时,再创建一个线程(名称:线程2)给user对象加锁。多个线程(共3个)竞争锁资源,使用重度锁。
public static void main(String[] args) throws InterruptedException {
User userTemp = new User();
System.out.println("无状态(001):" + ClassLayout.parseInstance(userTemp).toPrintable());
/*jvm默认延时4s自动开启偏向锁,可通过-XX:BiasedLockingStartupDelay=0 取消延时
如果不要偏向锁,可通过-XX:-UseBiasedLocking = false 来设置*/
Thread.sleep(5000);
User user = new User();
System.out.println("启用偏向锁(101):" + ClassLayout.parseInstance(user).toPrintable());
for (int i = 0; i < 2; i++) {
synchronized (user) {
System.out.println("使用偏向锁(101)(带线程id):" + ClassLayout.parseInstance(user).toPrintable());
}
System.out.println("释放偏向锁(101)(带线程id):" + ClassLayout.parseInstance(user).toPrintable());
}
// 创建了个新线程对user对象加锁
new Thread(()->{
synchronized (user){
System.out.println("轻量级锁(00):" + ClassLayout.parseInstance(user).toPrintable());
try{
System.out.println("睡眠3秒钟=====================");
Thread.sleep(3000);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}, "线程1").start();
// 再创建个新线程对user对象加锁
Thread.sleep(1000);
new Thread(() -> {
synchronized(user){
System.out.println("重量级锁(10):" + ClassLayout.parseInstance(user).toPrintable());
}
}, "线程2").start();
}
运行后,控制台部分日志如下:
上面截图中红色框为 锁状态位00,其他位保存指向重量级锁的指针。
锁升级问题
上面示例中,为什么才2,3个线程竞争资源,锁就升级到重量级锁了?
jdk1.8就是这样实现的。后续版本可能会持续优化。
源码看锁升级
我们把之前的A类编译后文件A.class进行反编译。A类代码如下:
public class A {
private AtomicInteger atomicInteger = new AtomicInteger(0);
int num = 0;
public void increase(){
synchronized (this){
num++;
}
}
public int getNum(){
return num;
}
}
找到A.class文件,执行:javap -c A.class,反汇编代码如下:
注意:synchronized代码块主要是靠monitorenter和monitorexit这两个原语来实现同步的。
monitor介绍:每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.这里涉及重入锁,如果一个线程获得了monitor,他可以再获取无数次,进入的时候monito+1,退出-1,直到为0,开可以被其他线程获取
如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
源码情况可以参考上面“jvm有序性—>内存屏障—>jvm底层源码”章节。
在src\share\vm\interpreter\interpreterRuntime.cpp找到monitorenter方法如下:
如果使用了偏向锁,在src\share\vm\runtime\synchronizer.cpp文件中。
如果没有使用偏向锁,在src\share\vm\runtime\synchronizer.cpp文件中。
cas
什么是cas
看上面“synchronized”章节下的"简单案例”部分,除了给A#increase()方法加锁(synchronized)还有什么方法可以保证线程安全呢?看下面代码:
public class A {
// 原子操作类
private AtomicInteger num = new AtomicInteger(0);
public void increase(){
num.incrementAndGet();
}
public int getNum(){
return num.get();
}
}
再次执行LockTest #main()方法,运行结果如下:
可见结果不仅是我们期望的,耗费时间也比加锁(synchronized)方式快不少。这个原子操作类AtomicInteger就用到了cas,我们可以点进去看下上面代码块的第5行代码num.incrementAndGet()方法,
看到这个方法名称为CompareAndSwapInt。
cas,即compare and swap(set) 比较并交换。
cas机制
cas一般被称为,无锁、自旋锁、乐观锁、轻量级锁。
我们来看下面这段源码Unsafe.class#getAndSetInt()
上面红色框中是一个do-while循环,用一段代码简单模拟处理逻辑,如下:
while(true){
// 获取该对象旧值
int oldValue = atomicInteger.get();
// 计算得到新值
int newValue = oldValue + 1;
// 如果当前引用该对象的地址当前存的值等于旧值,则把新值放进去,跳出循环
// 如果不等于,则继续循环
if(atomicInteger.compareAndSet(oldValue, newValue)) {
break;
}
}
现在有两个线程在执行。
线程2在修改变量值时,开始执行循环,分别得到旧值、新值。
如果旧值和该对象内存地址里面存的值一样(没有被线程1修改或者ABA问题),则把新值放进去。
如果旧值和该对象内存地址里面存的值不一样(该变量被线程1修改过了),则再次循环。
原子性问题
看上面截图中的源码362行这个关键步骤。事实上这个步骤不是一行代码啊!如果:
线程1 比较旧值和该对象内存地址存的值时,结果一样。 恰好这时
线程2 修改了该对象的值。接着
线程1 把新值赋值给该对象(线程1用新值覆盖了线程2的新值)
这时就发生了线程安全问题,即,比较和交换不是一步完成的。
那么是如何解决的呢?这里我们来看下jvm源码(源码情况可以参考上面“jvm有序性—>内存屏障—>jvm底层源码”章节)。我们一步步去找:
看上面截图中的源码362行。看这个this.compareAndSwapInt()方法的实现,如下:
这个方法是native修饰的,其真正的实现为C/C++,接下来在jvm源码(openjdk)中去找,
src\share\vm\prims\unsafe.cpp文件下的:
src\os_cpu\linux_x86\vm\atomic_linux_x86.inline.hpp文件里面
Aomic::cmpxchg()方法实现如下:
还是在该文件下,去看下LOCK_IF_MP()方法的实现,如下:
当线程1执行到这里获得锁,在释放该锁前。线程2不能获取到该锁去执行。
这里的锁一般是缓存行锁。如果占用内存比较大(超过64Byte),这里的锁会换成总线锁。
即 给最关键部分代码(比较并交换)加了锁,保证了cas的原子性,保证了线程的安全性。
总结:基于指令前缀lock + cmpxchg硬件原语 实现原子性
参考:《IA-32%20架构软件开发人员手册》7.1.2.2.软件控制的总线加锁 章节
ABA问题
先有两个线程来执行一段代码,都要修改一个变量的值,这个变量的类型为AtomicInteger类
线程1早执行,先读取了变量的值为A;
线程2执行的比较快,这时候把线程2把变量的值A修改为B再改为A;
此时,线程1执行了到比较并交换部分,比较时发现变量的值还是A,接着执行了下去。
这个就是cas的ABA问题。
这个ABA问题在某些场景下可以允许存在,在某些场景下必须杜绝。那么如何处理ABA问题呢?
可以引入版本号的概念,没修改一次对版本号做一次更新操作(恒加或恒减等)。java的rt.jar包为我们提供了两个类:AtomicStampedReference、AtomicMarkableReference。
AtomicStampedReference维护一个版本号,AtomicMarkableReference而是维护一个boolean类型的标记,用法没有AtomicStampedReference灵活。
下面来看示例:
使用AtomicInteger重线ABA问题,如下代码:
public class ABATest1 {
private static AtomicInteger value = new AtomicInteger(10);
public static void main(String[] args){
new Thread(() -> {
try{
System.out.println(Thread.currentThread().getName() + "; value的当前值是" + value.get());
TimeUnit.SECONDS.sleep(2);
boolean b = value.compareAndSet(10, 12);
System.out.println(Thread.currentThread().getName() + ":修改成功了吗?" + b + ", 设置的新值是:" + value.get());
}catch (InterruptedException e){
e.printStackTrace();
}
}, "线程1").start();
new Thread(() -> {
value.compareAndSet(10, 11);
value.compareAndSet(11, 10);
System.out.println(Thread.currentThread().getName() + ": 10->11->10");
}, "线程2").start();
}
}
执行结果如下:
接下来我们是AtomicStampedReference类,规避ABA问题,代码如下:
public class ABATest2 {
static AtomicStampedReference stampRef = new AtomicStampedReference(10, 1);
public static void main(String[] args) {
new Thread(() -> {
try{
int stamp = stampRef.getStamp();
System.out.println(Thread.currentThread().getName() + "第1次查看版本号为:" + stampRef.getStamp() + ",值为:" + stampRef.getReference());
TimeUnit.SECONDS.sleep(2);
boolean b = stampRef.compareAndSet(10, 12, stamp, stampRef.getStamp() + 1);
System.out.println(Thread.currentThread().getName()
+ ":修改是否成功?" + b + ",当前版本是:" + stampRef.getStamp() + ", 当前实际值是:" + stampRef.getReference());
}catch (InterruptedException e){
e.printStackTrace();
}
}, "线程1").start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "第1次查看版本号为:" + stampRef.getStamp() + ",值为:" + stampRef.getReference());
stampRef.compareAndSet(10, 11, stampRef.getStamp(), stampRef.getStamp() +1);
System.out.println(Thread.currentThread().getName() + "第2次查看版本号为:" + stampRef.getStamp() + ",值为:" + stampRef.getReference());
stampRef.compareAndSet(11, 10, stampRef.getStamp(), stampRef.getStamp() +1);
System.out.println(Thread.currentThread().getName() + "第3次查看版本号为:" + stampRef.getStamp() + ",值为:" + stampRef.getReference());
}, "线程2").start();
}
}
执行结果如下:
可见使用了AtomicStampedReference类,当线程2修改了变量值后再改回来,线程1去修改时没有成功。杜绝了ABA问题。
分段cas优化
cas的机制是什么?例如,前面使用AtomicInteger?
如上图,多个线程竞争一个资源处理时,一次只能有一个线程获取资源成功去处理,其他线程处于高度自旋(一直在循环),处理速度慢,性能还有待提升。
这里我们如果使用LongAdder呢,把”什么是cas“章节中代码A类修改下:
public class A {
LongAdder longAdder = new LongAdder();
public void increase(){
longAdder.increment();
}
public int getNum(){
return longAdder.intValue();
}
}
再次执行LockTest #main()方法(LockTest类见”synchronized“—>”简单案例“章节),运行结果如下:
这次只用了146ms比”什么是cas“章节运行时间261ms有所减少,可见性能有所提升。那么这个LongAdder类的运行机制怎样的呢?如下:
多个线程竞争一个资源时,例如,给一个变量增1。线程1首先获取到该变量进行处理,线程2没有获取到该变量(线程1还没有处理完成释放该变量资源)时,就会又产生一个该变量的副本供给线程2去处理。其他线程同样处理。接着会把变量和其所有副本一起去处理得到最终结果。
当然当线程非常多的时候,内存开销会特别大,该用重量级锁还要用。
AQS
概述
简介
AQS指的是AbstractQuenedSynchronizer抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制。
AQS的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
AQS原理:自旋 + lockSupport + cas 实现
问题重现
场景:减商品库存。当前《深入理解Java虚拟机》这本书的库存只有5本了,此时有30个用户在网上下订单去买这本书。
该商品的数据库表shop_order,数据如下:
后台代码如下
@Controller
public class CustomLockController {
@GetMapping("/lock")
@ResponseBody
public void lockTest() throws Exception {
int stock = DBUtil.query("select stock from shop_order where id=1");
if (stock <= 0){
System.out.println("下单失败,没有库存了");
return;
}
stock--;
DBUtil.update("update shop_order set stock=" + stock + " where id=1");
System.out.println("下单成功,当前剩余库存:" + stock);
}
}
现在用jmeter模拟同时30个人发送请求,请求路径:http://localhost:8080/lock
查看控制台输出:
这里发生了线程安全问题,当然解决的方法有很多。上面我们分析了AQS的思想,我们可以试着自己写一个同步器锁,来处理这个问题。
手写同步器锁
自旋 + LockSupport + cas,代码如下:
public class CddLock {
/**
* 当前加锁状态:0-未加锁;1-加锁 、 记录加锁次数
*/
private volatile int state = 0;
/**
* 当前持有锁的线程
*/
private Thread lockHolder;
/**
* 线程阻塞队列
*/
private ConcurrentLinkedDeque<Thread> queue = new ConcurrentLinkedDeque<>();
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
public Thread getLockHolder() {
return lockHolder;
}
public void setLockHolder(Thread lockHolder) {
this.lockHolder = lockHolder;
}
/**
* 尝试获取锁
* @return
*/
private boolean tryAquire(){
Thread t = Thread.currentThread();
int state = getState();
if(state == 0){
// 公平锁
if((queue.size()==0 || queue.peek()==t) && compareAndSwapState(0, 1)){
setLockHolder(t);
System.out.println(String.format("Thread-name:%s加锁成功", t.getName()));
return true;
}
}
return false;
}
/**
* 加锁
*/
public void lock(){
// 1. 获取锁-CAS
if(tryAquire()){
return;
}
Thread current = Thread.currentThread();
queue.add(current);
// 2. 没有获取到锁,停留在当前方法: 自旋 + LockSupport + cas
for(;;){
if(current==queue.peek() && tryAquire()){
queue.poll();
return;
}
LockSupport.park(current);
}
// 3. 锁被释放后,再次获取锁
}
/**
* 释放锁
*/
public void unLock(){
Thread current = Thread.currentThread();
if(current != lockHolder){
throw new RuntimeException("你不是持有锁的线程,不可以释放锁");
}
int state = getState();
if(compareAndSwapState(state, 0)){
System.out.println(String.format("Thread-name:%s释放锁成功", current.getName()));
setLockHolder(null);
Thread head = queue.peek();
if(head != null){
LockSupport.unpark(head); // 线程被唤醒
}
}
}
/**
* 原子操作
* @param oldValue 旧值
* @param newValue 新值
* @return
*/
public final boolean compareAndSwapState(int oldValue, int newValue){
return unsafe.compareAndSwapInt(this, stateOffset, oldValue, newValue);
}
private static final Unsafe unsafe = GetUnsafeInstance.getUnsafeInstance();
private static final long stateOffset;
static {
try {
stateOffset = unsafe.objectFieldOffset(CddLock.class.getDeclaredField("state"));
} catch (NoSuchFieldException e) {
throw new Error();
}
}
/**
* @Description 通过反射获取Unsafe类对象
* @Author cdd
* @Date 2021/6/21
* @Vesrion v1.0
**/
static class GetUnsafeInstance {
public static Unsafe getUnsafeInstance() {
try {
Class<?> clazz = Unsafe.class;
Field f = clazz.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(clazz);
return unsafe;
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (SecurityException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
return null;
}
}
}
使用我们上面写的同步器锁CddLock,针对上面“问题重现’'章节的案例,修改CustomLockController类,如下:
@Controller
public class CustomLockController {
// 实例化锁
CddLock cddLock = new CddLock();
@GetMapping("/lock")
@ResponseBody
public void lockTest() throws Exception {
cddLock.lock(); // 加锁
int stock = DBUtil.query("select stock from shop_order where id=1");
if (stock <= 0){
System.out.println("下单失败,没有库存了");
cddLock.unLock(); // 释放锁
return;
}
stock--;
DBUtil.update("update shop_order set stock=" + stock + " where id=1");
System.out.println("下单成功成功,当前剩余库存:" + stock);
cddLock.unLock(); // 释放锁
}
}
现在把数据库表shop_order表的库存再改为5,用jmeter再次测试,控制打印如下:
AQS的可重入性
线程能多次获取锁就叫可重入,这里可以用信号量标示,加一次锁给信号量做一次处理(比如:加1操作),释放锁时就做同样次数的反向处理(比如:减1操作)。
不可重入反之。
例如:在上面我们手写的同步器锁CddLock类中的state就是信号量,只有在state为0时才能加锁,加锁后把state改为1,这样就不能多次加锁,就不具备可重入性。
如果去掉”只有在state为0时才能加锁“的前提条件,每次加锁都给state加1,这样就具备可重入性了。
AQS的公平与非公平
这个是否公平体现在:
在使用同步器锁的过程中,可能一个线程获取到锁在执行中,其他线程都发放在队列里面。当这个线程执行结束释放锁时。这时候又有个新线程来了,那么是该哪个线程获取锁呢?
如果是队列里面对头的线程获取到锁,即按照先来后到的顺序获取锁 这个就是公平锁。
如果这个新线程获取锁(反之,没有让先入队列的线程获取锁)的话,这个就是非公平锁。
在实际通常使用ReentrantLock这个可重入同步器锁,该锁的结构图如下:
两个构造器,如下图:
源码中内部类FairSync,tryAcquire的实现如下图,红色框中部分体现了公平性。
源码中内部类NonfairSync,tryAcquire方法调用了父类Sync的tryAcquire方法,如下图:
源码中Sync类的nonfairTryAcquire()方法实现如下,红框中当前线程直接去获取锁了(就算还有等待时间更久的线程),体现了非公平性。
ThreadLocal
介绍
官方介绍
java.lang.Thread类上注释说:
ThreadLocal类用来提供线程内部的局部变量。这种变量在多线程环境下访问(通过get和set方法访问)时能保证各个线程的变量相对独立于其他线程内的变量。ThreadLocal实例通常来说都是private static类型的,用于关联线程和线程上下文。
我们可以得知ThreadLocalde的作用是:提供线程内的局部变量,不同线程之间不会干扰,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或组件之间一些公共变量传递的复杂度。
总结:
- 线程并发: 在多线程并发的场景下
- 传递数据: 我们可以通过ThreadLocal在同一线程,不同组件中传递公共变量
- 线程隔离: 每个线程的变量都是独立的,不会互相影响
基本使用
常用方法如下:
方法声明 | 描述 |
---|---|
ThreadLocal() | 创建ThreadLocal对象 |
public void set( T value) | 设置当前线程绑定的局部变量 |
public T get() | 获取当前线程绑定的局部变量 |
public void remove() | 移除当前线程绑定的局部变量 |
接下来看个案例,有代码如下:
public class MyDemo01 {
private String content;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public static void main(String[] args) {
MyDemo01 demo = new MyDemo01();
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
demo.setContent(Thread.currentThread().getName() + "的数据");
System.out.print("");
System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
}
}, "线程"+i);
thread.start();
}
}
}
执行结果如下:
可以看到后面两个线程保存的线程名称和打印的线程名称不一致,各个线程没有隔离,发生了安全问题。那么如何解决呢?前面我们学习了很多关于线程安全的解决方法,这里我们使用ThreadLocal试试。代码段如下:
public class MyDemo01 {
// 给每个线程绑定的局部变量
private ThreadLocal<String> t1 = new ThreadLocal<>();
private String content;
public String getContent() {
return t1.get();
}
public void setContent(String content) {
t1.set(content);
}
public static void main(String[] args) {
MyDemo01 demo = new MyDemo01();
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
demo.setContent(Thread.currentThread().getName() + "的数据");
System.out.print("");
System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
}
}, "线程"+i);
thread.start();
}
}
}
执行结果如下:
现在每个线程打印的都是自己的线程名称,线程完全隔离,线程安全。
与synchronized比较
对于上面的线程安全问题,如果不使用ThreadLcal类。使用synchronized关键字该怎么处理呢?代码段如下:
public class MyDemo02 {
private String content;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public static void main(String[] args) {
MyDemo02 demo = new MyDemo02();
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(() -> {
synchronized (MyDemo02.class){
demo.setContent(Thread.currentThread().getName() + "的数据");
System.out.print("");
System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
}
}, "线程"+i);
thread.start();
}
}
}
执行结果如下:
可以看到每个线程打印的都是自己的线程名称,线程也是安全的。
那么ThreadLocal和synchronized的区别在哪里呢?
synchronized | ThreadLocal | |
---|---|---|
原理 | 同步机制采用’以时间换空间’的方式, 只提供了一份变量,让不同的线程排队访问 | ThreadLocal采用’以空间换时间’的方式, 为每一个线程都提供了一份变量的副本,从而实现同时访问而相不干扰 |
侧重点 | 多个线程之间访问资源的同步 | 多线程中让每个线程之间的数据相互隔离 |
总结:
在刚刚的案例中,虽然使用ThreadLocal和synchronized都能解决问题,但是使用ThreadLocal更为合适,因为这样可以使程序拥有更高的并发性。
与Thread配合使用原理
ThreadLocal内部结构
ThreadLocal类的内部结构图如下:
ThreadLocal类有:
2个内部类。ThreadLocalMap(还有内部类) 和 SuppliedThreadLocal(继承于ThreadLocal)。
3个核心方法。get()、set()、remove(),部分实现依靠ThreadLocalMap类。
这里看下重要的内部类ThreadLocalMap。内部结构如下图:
ThreadLocalMap类中重要内容有:
1一个内部类。Entry,该类继承于弱引用类WeakReference。
Entry类型的数组table。下标为ThreadLocal类对象(this)算出的哈希数值。
方法getEntry()、set()、remove()对数组table元素操作。被ThreadLocal类get()、set()、remove()方法调用。
Thread的重要字段
我们来进行源码追踪,找下这个重要字段。
先来看ThreadLocal#set()方法,如下图:
看ThreadLocal#get()方法,如下图:
再来看下这个t.threadLocals的声明,在Thread类里面,如下图:
这个字段就是Thread类的ThreadLocal.ThreadLocalMap类型的threadLocals字段。
图解使用原理
线程Thread里面维护一个ThreadLocal.ThreadLocalMap类型的字段threadLocals;
threadLocals字段指向的ThreadLocalMap类对象有一个Entry类型的数组;
Entry的外部类的外部类是ThreadLocal;
Entry类对象存储着this,即,ThreadLocal类对象为key;当前线程绑定的局部变量为value;
总结:
线程Thread携带着一个Entry类型的数组;
该数组的元素类型ThreadLocal内部类的内部类Entry;
该元素存贮着ThreadLocal类对象和当前线程绑定的局部变量。
核心源码分析
ThreadLocal类中:set()、get()、 remove()
public void set(T value) {
Thread t = Thread.currentThread();
// 获取当前线程携带的ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
if (map != null)
// 调用ThreadLocalMap的set方法
map.set(this, value);
else
// 给当前线程的threadLocals字段赋值
createMap(t, value);
}
// 获取当前线程携带的ThreadLocalMap对象
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
// 给当前线程的threadLocals字段赋值
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
public T get() {
Thread t = Thread.currentThread();
// 获取当前线程的ThreadLocalMap类型对象
ThreadLocalMap map = getMap(t);
if (map != null) {
// 根据当前this->ThreadLocal对象获取Entry
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
// 获取Entry对象的value并返回
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 初始化并返回
return setInitialValue();
}
// 初始化
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
// 一个初始化值
protected T initialValue() {
return null;
}
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
// 调用ThreadLocalMap的remove方法移除元素
m.remove(this);
}
内部类ThreadLocalMap中:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
// 根据传来key,也就是this对象,即,ThreadLocal类对象 得到 哈希值
int i = key.threadLocalHashCode & (len-1);
// 解决hash冲突的方法:线性勘探法
// 遍历线程携带的数组,从哈希值i为下标之后遍历
// 如果存在元素的key等于要保存的key,则更新该元素的value;如果某个位置元素为空,则把value保存到这个位置上
// 一直向后“勘测”,下标i向后移动
for (Entry e = tab[i]; e != null;e = tab[i = nextIndex(i, len)]) {
// 获取到每个元素的key
ThreadLocal<?> k = e.get();
// 如果要保存的key和已有元素的key相同,则更新该元素的value
if (k == key) {
e.value = value;
return;
}
// 如果某个元素的key为null,则把value保存在这个位置
if (k == null) {
// 用当前要保存的value、key构成的Entry对象替换旧的Entry对象
replaceStaleEntry(key, value, i);
return;
}
}
// 保存当前key、value组成的Entry对象
// 此时i已经移动到一个没有放元素的位置上(下标)
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
// 根据key获取元素
private Entry getEntry(ThreadLocal<?> key) {
// 获取下标
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// 从数组中取出的元素不为null且key与要查询的key相等,则返回该元素
if (e != null && e.get() == key)
return e;
else
// 线性勘探,向后寻找
return getEntryAfterMiss(key, i, e);
}
// 移除元素
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
// 遍历数组获取到该元素
for (Entry e = tab[i];e != null;e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
// 元素的key置为null
e.clear();
// 清除该元素
expungeStaleEntry(i);
return;
}
}
}
弱引用导致的内存泄漏吗
在使用ThreaLocal类绑给线程绑定局部变量,有时间可能发生内存泄漏,有人推测这个和ThreadLocal.ThreadLocalMap.Entry使用了弱引用有关,接下来我们一探究竟。
Entry结构
Entry的结构及继承关系,如下图:
Entry类继承了WeakReference类。
Entry类构造方法为:Entry(ThreadLocal<?> k, Object v)。
Entry的k为ThreadLocal类对象的确为弱引用。
什么是弱引用
参考文章https://blog.csdn.net/weixin_41968788/article/details/113122921的6.3.1章节。
java 的引用类型一般分为 4 种:强、软、弱,虚。
- 强引用:普通的变量引用。例如,我们常在代码里面用User u1 = new User()就是。
- 弱引用:将对象用 WeakReference 弱类型的对象包裹,弱引用跟没引用差不多,GC 直接回收掉,很少用。
分析
当Entry中的k使用了弱引用,使用时引用情况如下图(虚线为弱引用,实线为强引用)
- a.假设在业务代码中使用完ThreadLocal ,threadLocal Ref被回收了。
- b. 由于ThreadLocalMap只持有ThreadLocal的弱引用,没有任何强引用指向threadlocal实例, 所以threadlocal就可以顺利被gc回收,此时Entry中的key=null。
- c. 但是在没有手动删除这个Entry以及CurrentThread依然运行的前提下,也存在有强引用链 threadRef->currentThread->threadLocalMap->entry -> value ,value不会被回收, 而这块value永远不会被访问到了,导致value内存泄漏。
也就是说,ThreadLocalMap中Entry的key使用了弱引用, 也有可能内存泄漏。
当Entry中的k使用了强引用,使用时引用情况如下图
- a. 假设在业务代码中使用完ThreadLocal ,threadLocal Ref被回收了。
- b. 但是因为threadLocalMap的Entry强引用了threadLocal,造成threadLocal无法被回收。
- c. 在没有手动删除这个Entry以及CurrentThread依然运行的前提下,始终有强引用链 threadRef->currentThread->threadLocalMap->entry,Entry就不会被回收(Entry中包括了ThreadLocal实例和value),导致Entry内存泄漏。
也就是说,ThreadLocalMap中的Entry的key使用了强引用, 是无法完全避免内存泄漏的。
内存泄漏根本原因 没有手动调用remove,删除这个Entry 且 线程一直在运行。
避免的方法 手动调用remove删除Entry。(线程不好控制其销毁,特别是线程池情况)
为什么使用弱引用
通过上面的分析,我们知道无论ThreadLocalMap中Entry的key使用哪种类型引用都无法完全避免内存泄漏,跟使用弱引用没有关系。
使用了弱引用时,当 Entry中的key为null时,ThreadLocalMap中的set(),getEntry()会进行一些处理。
ThreadLocalMap#set()方法中代码片段如下:
ThreadLocalMap#getEntry()方法中调用到了getEntryAfterMiss(),接着又调用到了expungeStaleEntry(),代码片段如下:
这就意味着使用完ThreadLocal,CurrentThread依然运行的前提下,就算忘记调用remove方法,弱引用比强引用可以多一层保障:弱引用的ThreadLocal会被回收,对应的value在下一次ThreadLocalMap调用set,get,remove中的任一方法的时候会被清除,从而避免内存泄漏。