Java并发
CAS
compare and swap 比较并交换,cas又叫做无锁,自旋锁,乐观锁,轻量级锁
例如下面的代码,如果想在多线程情况下保证结果的正确性,可以使用synchronized
public class A {
private int i;
public synchronized void addI(){
i++;
}
public int getI() {
return i;
}
}
public class ConcurrencyDemo {
public static void main(String[] args) throws InterruptedException {
long startTime=System.currentTimeMillis();
A a = new A();
Thread thread = new Thread(() -> {
for (int i = 0; i < 1000000; i++) {
a.addI();
}
});
thread.start();
for (int i = 0; i < 1000000; i++) {
a.addI();
}
thread.join();
System.out.println(a.getI());
long endTime=System.currentTimeMillis();
System.out.println("程序运行时间: "+(endTime-startTime)+"ms");
}
}
结果 2000000
程序运行时间: 109ms
而使用AtomicInteger类来进行相同的操作
private AtomicInteger atomicInteger=new AtomicInteger();
public int getCAS() {
return atomicInteger.get();
}
public void addCAS(){
atomicInteger.incrementAndGet();
}
结果 2000000
程序运行时间: 67ms
缩短了不少时间,将循环数量调整到1亿次效果更加明显
使用synchronized 程序运行时间: 7512ms
使用atomicInteger类 程序运行时间: 2521ms
结果时间缩短了将近3倍,为什么atomicInteger类比synchronized关键字缩短这么长时间呢
当使用synchronized时,如果在非静态方法上锁住的是对象,因为上面只有一个对象,也就是一个锁,当这两个线程去获取锁时同一时间内只会有一个线程获取到了,而没有获取到的线程被添加到一个阻塞队列,只能等待着上一个线程释放锁,即线程阻塞,当释放完锁需要唤醒其他线程来获取锁,其中还有上下文切换,还要找到该线程上次运行位置,操作系统进行线程调度等等,消耗资源比较多
那atomic类工作原理是什么呢?
比较并交换
原理很简单,当它进行加一操作时,并不是直接进行加一然后赋值,首先获取到旧值,然后进行+1操作,最后比较内存中的值是否和取出来的旧值是否一样,如果一样则进行赋值,否则进行重试
总共就3步,获取数据,进行加1,比较原始数据和内存中的数据,如果相同赋值,否则重试
原子性
那么比较并交换是一个原子性操作吗,光听着名就感觉是两个操作,比较,交换,如果在比较后又有其他线程进行修改值呢
这个atomic方法点到最后是一个本地方法,无法看到内部实现了,
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
但是在JVM源码中它底层汇编的实现会在比较交换前添加一个lock指令
因为不懂汇编在网上搜了一下lock指令的作用:LOCK指令前缀会设置处理器的LOCK#信号(译注:这个信号会使总线锁定,阻止其他处理器接管总线访问内存),直到使用LOCK前缀的指令执行结束,这会使这条指令的执行变为原子操作。在多处理器环境下,设置LOCK#信号能保证某个处理器对共享内存的独占使用。
https://blog.csdn.net/imred/article/details/51994189
所以可以将比较并交换看做为一个原子性的操作,不用担心在比较后值进行了变化
ABA
还有一种情况,在获取值,增加1,比较交换三步中,当线程A在第一步获取完数据假设为3,正在进行下面的操作时,线程B将数据3改为了4,然后又改回了3,线程A继续执行加1,在进行比较交换时判断正确,原来是3当前代码中旧值也是3,然后进行了赋值
例如你买了瓶可乐,放桌上没来及喝有事出去了,这时小明刚从外面回来非常渴把你的可乐喝了,然后又去商店给你买了一瓶一样的放回去,你回来并没有发现有什么不同,可乐还是可乐,但是这瓶可乐已经不是你自己买的那一瓶了
这个也不算一个问题,因为结果正确,但又算一个问题,因为比较的数据已经和最开始获取的数据并不是同一个,如果想要解决这个问题添加一个版本号即可,在每次进行比较交换时同时判断版本号,上面的例子中如果使用了版本号,线程A最后判断旧值和版本号,例如版本号默认为1,B线程进行两次修改,版本号为3,A线程在比较并交换时同时判断版本号和旧版本号,如果不同则不进行交换
在java中有一个类可以解决这个问题,AtomicStampedReference
这里就不再细讲了,有兴趣可以去看看
锁升级
一个对象的锁有4中状态:无锁,偏向锁,轻量级锁,重量级锁
一个对象的锁信息都会存储在对象头的运行时元数据(Mark Word)中,在MarkWord中不仅仅存储锁的信息,还有哈希值,GC年龄等等,具体可以看这篇文章
64位虚拟机对象头中的数据
偏向锁
偏向锁是啥?当线程A访问代码并获取锁时,会在对象头的markword中存储这某个线程的id,当下次这个线程进行操作时先判断和存储在对象头中的线程id是否相同,如果相同则直接进行操作,不需要进行加锁
如果不一致,例如线程B也访问代码块尝试获取锁时,首先判断记录在对象头中的线程A是否存活,如果没有存活则将锁状态设置为无锁,线程B竞争时将对象头中线程id设置为B线程的id,如果线程A存活,则查找这个线程的栈帧信息,如果还是需要持有这个锁对象则暂停线程A,撤销偏向锁,将锁升级为轻量级锁,如果不需要再持有线程A的锁则将锁设置为无状态,重新设置偏向锁
为什么添加偏向锁?在应用程序中大部分时间并不存在锁的竞争,如果还是使用重量级锁进行一系列操作浪费了许多无用的资源,但是如果不加锁在一些出现线程竞争的时候,就无法保证数据的准确性
偏向锁是默认是在jvm启动后4秒开启的,如果不想有延迟可以在启动参数中添加:XX:BiasedLockingStartUpDelay=0
如果不需要偏向锁可以添加-XX:-UseBiasedLocking = false
来设置
什么时候升级:线程A只要进行一次访问后,在对象头markWord中存储了线程A的id,只要下次访问的线程ID和上次存储的不一致符合上面的条件则升级为偏向锁
轻量级锁
使用CAS进行轻量级的获取锁,如果没有获取到根据条件升级为重量级锁
过程:
- 在当前虚拟机栈帧中创建一份锁记录(LockRecord)的空间,DisplacedMarkWord
- 首先将对象头中的markWord复制一份到当前栈帧的锁记录中
- 然后使用CAS将对象头的内容改为指向线程存储锁记录的地址
- 如果在线程A复制对象头后,对象头中的markWord还没有更换之前,线程B也准备获取锁,复制对象头到线程B的锁记录中,在线程B使用CAS进行替换对象头时发现,线程A已经将对象头中数据改变了,则线程B的CAS失败,尝试10次CAS来获取锁,如果没有获取到则升级为重量级锁
https://edu.51cto.com/study/11144 可以看这篇博客
什么时候升级:当线程A获取到了对象锁,线程B也进行了访问尝试通过CAS获取,自旋10次后还是没有等到线程A释放锁则升级为重量级锁,如果在10次内获取到了则还是轻量级锁
因为在JDK1.6之前synchronized是一个重量级锁,比较消耗资源,在JDK1.6之后对synchronized进行了优化,当使用synchronized修饰并不会默默认初始一个重量级锁,而是先使用偏向锁->轻量级锁->重量级锁
锁升级的条件
导入包jol-core
OpenJDK提供了JOL包,可以在运行时查看对象,类的细节
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
下面的代码演示锁升级
public class LockUpDemo {
public static void main(String[] args) throws InterruptedException {
A a1 = new A();
System.out.println(ClassLayout.parseInstance(a1).toPrintable()); ///====第一个输出
//等待JVM开启偏向锁
Thread.sleep(5000);
A a = new A();
System.out.println(ClassLayout.parseInstance(a).toPrintable()); ///====第二个输出
synchronized (a) {
System.out.println(ClassLayout.parseInstance(a).toPrintable()); ///====第三个输出
}
new Thread(() -> {
synchronized (a) {
System.out.println(ClassLayout.parseInstance(a).toPrintable()); ///====第四个输出
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
//等待一会防止两个线程同时抢夺锁导致直接升级为重量级锁
Thread.sleep(500);
new Thread(() -> {
synchronized (a) {
System.out.println(ClassLayout.parseInstance(a).toPrintable()); //====第五个输出
}
}).start();
}
}
每次输出前三行都是对象头中的信息,在第一行中存储markWord的线程信息
==>0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)==<--这一行
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
第一个输出
com.jame.concurrency.cas.A object internals: ====主要看这段====
OFFSET SIZE TYPE DESCRIPTION VALUE ↓
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 int A.i 0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
先看第一个输出,在第一行数据中,在小箭头指向位置为00000001,存储这偏向锁(1bit)和锁信息(2bit),也就是最后的001
对比这上面的图,0为偏向锁标识,也就是没有启用偏向锁,前面说过在JVM启动后偏向锁会延迟一会再启动,所以这里为0 而后面的01对应着锁的标识,也就是偏向锁
第二个输出
com.jame.concurrency.cas.A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 int A.i 0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
前面代码等待了5秒,能看到101
已经启动偏向锁了
第三个输出
com.jame.concurrency.cas.A object internals: ===注意这些数据和上次输出发生了改变===
OFFSET SIZE TYPE DESCRIPTION VALUE ↓ ↓ ↓
0 4 (object header) 05 e8 8b 02 (00000101 11101000 10001011 00000010) (42723333)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 int A.i 0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
因为启动了偏向锁,而这次输出是在synchronized中进行的,也就是进行了获取锁的操作,对比上次输出能发现在锁信息后面多了一些其他数据,而这些数据中就包含了当前线程的id
第四个输出
com.jame.concurrency.cas.A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 78 f0 f5 20 (01111000 11110000 11110101 00100000) (552988792)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 int A.i 0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
又创建了一个线程进行获取锁的操作,能看到这里已经升级为轻量级锁了000
第五个输出
com.jame.concurrency.cas.A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) aa f3 2b 1d (10101010 11110011 00101011 00011101) (489419690)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 int A.i 0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
创建一个新线程获取锁,后三位010
现在已经升级为重量级锁了,也就是原来理解的synchronized,原因就是上一个获取锁睡眠了1500ms,而sleep不会释放掉锁,所以获取不到,产生争抢锁,升级为了重量级锁
注意:锁的升级是不可逆的,即一旦从偏向锁升级为轻量级锁,轻量级升级为重量级锁,则不能再降级,但是偏向锁可以再设置回无锁的状态