上篇文章简单介绍了多线程开发会遇到的问题。现在我们知道因为
JMM
模型导致,线程之间的可见只能通过将线程本地内存的值更新到主内存中,然后更新其他线程里的值保证可见。但是在多线程的情况下,我们如果真的等一个线程结束之后,才能知道某个值的结果,那其实相当于线程之间的变量,就是不可见的。然后因为编译器和处理器为了提升运行效率的重排序,导致虽然单线程的结果没有改变,但是多线程之间互相访问的值变化的时机,会超出我们的预测范围,乱序执行引发不必要的问题。还有就是原子性的问题,线程之间的调度都是由CPU进行调度,因为无法预测的CPU执行顺序,导致在多线程的处理过程中,无法保证最终结果的原子性。
volatile
使用
volatile关键字有两层语义,第一层是保证变量的可见性,第二层是防止代码重排序。先简单看下大部分场景下的使用
package cn.yarne;
/**
* Created by yarne on 2021/9/7.
*/
public class Main {
private static boolean canContinue = false;
// private static volatile boolean canContinue=false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (!canContinue) {
//死循环
}
System.out.println("子线程结束");
});
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(3000);
canContinue = true;
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
thread2.start();
thread.join();
System.out.println("主线程结束");
}
}
上边可以看到,使用volatile和不使用volatile是完全两个不一样的结果。也就是说加了volatile,让canContinue这个参数修改为true之后,让第一个子线程检测到,才能跳出循环继续运行结束,否则就是死循环。那它是如何实现这个效果的呢?
理解
javap -c -p -verbose D:\workspace-1\spay\untitled\out\production\untitled\cn\yarne\Main.class
//部分片段
...
private static volatile boolean canContinue;
descriptor: Z
flags: ACC_PRIVATE, ACC_STATIC, ACC_VOLATILE
public cn.yarne.Main();
descriptor: ()V
flags: ACC_PUBLIC
Code:
...
首先通过之前讲过的java字节码部分的知识,将Main.java转化为助记符查看,会发现其实增加了volatile和没有增加volatile,最终指令调用没有发生任何的改变,最终变化的是变量的标识,flags
中多了一个ACC_VOLATILE
标识,所以可以理解为volatile并不是在编译级别处理的,而是通过标识告诉后边的操作,需要对这个变量做一个volatile的操作,具体是如何做的呢?
Lock前缀
其实最终,在汇编编译的时候,如果发现了变量的flags有volatile,就会在指令前加一个lock前缀,Lock前缀的作用就是完成volatile所有机制的一个实现。它有如下几个作用
- 指定前边的代码不能跟后边的代码互换位置(防止重排序,前边的代码执行完之后才会执行后边)
- 如果是write操作,指令完成之后第一时间将变量刷新到主内存中,并且基于缓存一致性协议将其他对该变量的缓存置于无效。(间接的实现了线程之间的可见性)
synchronized
使用
synchronized也有两层语义,第一层是保证线程操作的原子性,第二层是保证内部变量的可见性,先看一个应该大家都看过无数遍的一个最简单的例子(代码简单,理解起来就简单)。
package cn.yarne;
import java.util.concurrent.CountDownLatch;
/**
* Created by yarne on 2021/9/7.
*/
public class Main {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
new Thread(() -> {
add();
countDownLatch.countDown();
}).start();
}
countDownLatch.await();
System.out.println(count);
}
public synchronized static void add() {
for (int i = 0; i < 10000; i++) {
count += 1;
}
}
/*public static void add() {
synchronized (Main.class) {
for (int i = 0; i < 10000; i++) {
count += 1;
}
}
}*/
/*public static void add() {
for (int i = 0; i < 10000; i++) {
count += 1;
}
}*/
}
理解
synchronized这个关键字我们其实很早就开始接触,其实从很我们早知道,这个关键字加上,就是锁上了。如果加在方法上,那就是把这个方法锁上了,如果单独拎出来一块圈上synchronized,那这个圈内部的资源就锁上了,每个线程访问锁内的资源的时候,需要去竞争先拿到这个锁。然后才能进到内部进行操作。可以理解为不论多少线程在一起抢着这把锁,但是最终执行这块资源的时候,就是串行执行。因为synchronized可以直接加在方法上,也可以单独代码块进行添加。所以在查看字节码的时候,会看到不同的效果,我这边分别列举出来,依旧使用上边用过的javap命令查看
加在方法上
public synchronized static void add() {
for (int i = 0; i < 10000; i++) {
count += 1;
}
}
public static synchronized void add();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED //重点看这部分
Code:
stack=2, locals=1, args_size=0
0: iconst_0
1: istore_0
2: iload_0
3: sipush 10000
6: if_icmpge 23
9: getstatic #10 // Field count:I
12: iconst_1
13: iadd
14: putstatic #10 // Field count:I
17: iinc 0, 1
20: goto 2
23: return
...
锁代码块
public static void add() {
synchronized (Main.class) {
for (int i = 0; i < 10000; i++) {
count += 1;
}
}
}
public static void add();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=0
0: ldc #12 // class cn/yarne/Main
2: dup
3: astore_0
4: monitorenter //进入锁
5: iconst_0
6: istore_1
7: iload_1
8: sipush 10000
11: if_icmpge 28
14: getstatic #10 // Field count:I
17: iconst_1
18: iadd
19: putstatic #10 // Field count:I
22: iinc 1, 1
25: goto 7
28: aload_0
29: monitorexit //正常退出锁
30: goto 38
33: astore_2
34: aload_0
35: monitorexit //异常退出锁
36: aload_2
37: athrow
38: return
...
锁进入以及锁释放
首先可以看到,如果直接加在方法上,基本上和volatile一样,只是多一个flag标识ACC_SYNCHRONIZED
,但是放在代码块上,就不一样了,我们可以真真切切的看到锁进入和锁退出的过程,通过monitorenter
表示拿到锁并且要进入内部指令,monitorexit
表示进行释放锁资源,退出锁着的资源。这两个标识的作用如下:
monitorenter
:如果锁着的指令块内部有用到主内存的变量,就将工作内存上这些变量的值清除,需要用的时候,直接去主内存里拿。并且一旦进入,就是串行执行开始monitorexit
:如果锁着的指令块内部有对主内存变量的操作,退出的时候立马刷新到主内存中,并且基于缓存一致性触发让其他使用这些变量的缓存失效,串行化结束
所以从两个锁进入,锁退出,首先因为让主内存的变量的操作及时的刷新,保证了可见性,另外一个是因为是串行执行,不管其内部代码是否有重排序,最终串行执行的结果必须是原子性的,所以保证了锁着的资源的原子性。至于为什么要退出两次,其实是因为锁内部资源可能在操作过程中会出现异常,如果出现异常但是还没到正常退出的时候,那么第二个monitorexit就可以保证锁资源依然可以被正常的释放
Atomic
为什么把这个加上呢,是因为在写总结的时候问问题的时候,突然想到了这个,虽然上边两种是可以解决我们绝大部分场景下的三大特性引发的问题,但是在小部分场景下,类似于计数这些场景,我们没有必要去用synchronized
这种其实用起来会比较耗费资源的关键字,为什么这么说呢,因为锁不论怎么用,最终都还是会多一个锁资源的竞争
操作,这种场景下我们可以直接使用一个无锁
的方式,就是JUC
提供的atomic包
中的类,它有各种的实现,可以直接实现原子操作。
使用
package cn.yarne;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Created by yarne on 2021/9/7.
*/
public class Main {
private static AtomicInteger count = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
new Thread(() -> {
add();
countDownLatch.countDown();
}).start();
}
countDownLatch.await();
System.out.println(count);
}
public static void add() {
for (int i = 0; i < 10000; i++) {
count.incrementAndGet();
}
}
}
CompareAndSweep
为什么可以无锁实现原子操作呢,其实最终的奥义就是compareAndSweep
比较替换,是一个乐观锁CAS
的实现,在更改值的时候,首先要判断改时候的值是跟读取时候的值一样,才会进行修改,否则重新读取最新的值,进行比较替换。最终就靠一个比较的动作完成变量的原子性。可以简单看下编译后的字节码
public static void add();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=0
0: iconst_0
1: istore_0
2: iload_0
3: sipush 10000
6: if_icmpge 22
9: getstatic #10 // Field count:Ljava/util/concurrent/atomic/AtomicInteger;
12: invokevirtual #12 // Method java/util/concurrent/atomic/AtomicInteger.incrementAndGet:()I
15: pop
16: iinc 0, 1
19: goto 2
22: return
...
就跟上边说的一样,最终其实真正重要的步骤是第6步,if_icmpge
。当然,它只保证自己支持类型变量的原子性
。
总结
暂时只是简单介绍了volatile
和synchronized
是如何保证可见性,有序性,原子性。还有天然的原子类Atomic
,当然,不论是哪个都还有更多的知识需要我们去了解,我们可以考虑一下,这三个特性,除了这两个关键字,还有其他的一些方式方法或者关键字可以保证吗?了解这这些线程之间的特性的解决方式,可以让我们在开发多线程的时候,避免各种因为这三个性质导致的问题,提前免疫。之后的文章会介绍一下关于synchronized
关键字以及Atomic
类的更详细的一些知识点。