Java原生语法synchronized与volatile区别与作用详解(一)
「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」
前言
- 我们了解了对象的内存模型,也了解了Java中偏向锁、轻量级锁、重量级锁之间的关系。这些都是Java偏低层的知识,今天我们来聊聊我们多线程开发中常见的两个关键字,或者说源码偏爱的代码关键字
volatile
volatile保证了内存可见性以及防止指令重排序
内存可见性
- 这就是volatile这个关键字的作用。但是volatile并不是彻底能解决多线程下变量不一致的问题。我们还需要辩证的看待此关键字。
- 关于
volatile
理解之前我们需要理解JVM在分配内存上的策略。谈到JVM我一直想开这个章节,奈何时间有限没时间细细学习下JVM。我们大概看下JVM堆栈对于线程的分布
- 对象内存分布模型我们了解了。但是内存分配在哪里是由JVM控制的。在JVM中对象不出意外是存储在堆中的(这里不谈栈上分配内存和内存逃逸技术)。那么栈中存储啥呢?栈中存储我们变量的引用指向对中。在栈中还有一个虚拟机栈的概念。在虚拟机栈中为每个线程开辟一块空间。每个线程的操作就在各自的空间里完成。这就做到了线程之间隔离。正是因为这层隔离导致我们变量不同步了。在上图中我们也能够看到每个线程在第一次用到变量的时候会从主内存(堆)中拷贝一份数据到自己空间里,后续该线程针对变量的所有操作仅是针对自己内部的变量的操作的。操作完自己内部变量会将最新内容回写到主内存(堆)中。
- 因为仅第一次会同步主内存数据,所以线程A针对对象M的修改如果是在线程b同步之后的话,那么线程b的对象M将是不会受影响的。但是如果我们修改的操作是在线程b同步之前进行的就没事。
public class Volatile {
static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
public void run() {
while (flag) {
}
System.out.println("flag被改了,我也要走了哦。。。。。。");
}
},"hellone").start();
Thread.sleep(1000);
flag=false;
System.out.println("我是主线程");
}
}
-
上述代码由两个线程,分别是主线程和线程hellone , 重点就是在
Thread.sleep(1000)
上。如果没有这段代码很大概率是主线程先执行,因为线程hellone启动还是需要时间的,所以如果代码中没有休眠的话,我们看到的效果就是能够正常结束的,且先输出我是主线程
后输出flag被改了,我也要走了哦。。。。。。
-
但是当我们加入休眠,这个时候程序是永远不会停止的。因为在线程hellone中flag永远是true , 不管主线程怎么修改,hellone线程永远不会再从主内存中同步flag的🈯️了。
-
上述代码的解决就是引入
volatile
关键字。因为他的作用之一就是内存可见性。在flag上添加volatile就能够保证线程中每次使用该变量的时候都会从主内存中同步一份到当前虚拟机栈的线程中。
public class Volatile {
static volatile boolean flag = true;
static int index=0;
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
public void run() {
while (flag) {
index++;
}
System.out.println("flag被改了,我也要走了哦。。。。。。"+index);
}
},"hellone").start();
Thread.sleep(1000);
flag=false;
System.out.println("我是主线程");
}
}
- 最终执行效果如下
- 为什么加上index , 是为了让你看出hellone线程循环次数。volatile只是保证线程可见性。但不保证线程绝对安全,因为从主内存中同步数据也是需要时间的。这里index 也不能完全说明volatile不能保证线程安全。我们在看下面代码
public class VolatileAddMoreThread {
static volatile Integer index = 0;
public static void main(String[] args) throws InterruptedException {
while (true) {
index = 0;
int m = 100;
final int n = 100;
List<Thread> threadList = new ArrayList<Thread>();
for (int i = 0; i < m; i++) {
Thread thread = new Thread(new Runnable() {
public void run() {
for (int i1 = 0; i1 < n; i1++) {
index++;
}
}
});
thread.start();
threadList.add(thread);
}
for (Thread thread : threadList) {
thread.join();
}
System.out.println(index);
if (index < m * n) {
System.out.println("线程出现不安全index="+index);
break;
}
}
}
}
- 上面这段代码简单分析下。类中有一个被volatile修饰的整数变量。然后由100个线程不断的对整数进行递增操作。如果volatile保证线程安全理论上index=100*100=10000 ; 而实际上不管怎么运行很少情况是10000 。 这里就不贴图了,感兴趣自己复制下去执行看情况吧。
- 这里我只是想说明volatie保证可见性,但多线程下还是有经典问题,多个线程同时读到相同内容进行递增。从而导致最终index<=10000。
- 这里是为什么呢?主要还是因为index++是非原子性导致的。实际上在汇编上i++是多条汇编指令执行的。当执行部分指令是还没有写会主内存被CPU切换了。这个时候值其实已经变化了但是主内存还没来得及,所以导致其他线程读到旧值从而导致index<=10000
禁止指令重排序
- 上面我们了解了volatile可以做到内存可见性,这样能够让我们线程之间变量最终一致性。但是还是存在线程不安全问题,存在着方便问题是因为我们在线程中操作是非原子性操作。
- 经过上面我们已经得出结论是volatile不保证线程安全,下面看看volatile另外一个特性
禁止指令重排序
public class InstructionReorder {
static int a;
static int b;
static int x;
static int y;
public static void main(String[] args) throws InterruptedException {
int index=0;
while (true) {
index++;
a=0;b=0;y=0;x=0;
final Thread thread = new Thread(new Runnable() {
public void run() {
a = 1;
x = b;
}
});
final Thread thread1 = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
thread.start();thread1.start();
thread.join();thread1.join();
if (x == 0 && y == 0) {
System.out.println("index="+index+"x,y="+x+y);
break;
}
}
}
}
- 上面这段代码在运行之前我嗯先来分析下他的意图。首先有一个死循环不断的创建两个线程,分别是给a,b,x,y进行赋值。逻辑很简单我们在看看每次循环中x,y的值分别是多少。
- 这里我们还得强调一点,多线程的执行并不是从头到尾,因为cpu执行速度很快他会进行来回切换的。所以在thread中执行有可能到a=1 , 就会被CPU切换到thread1中执行b=1的逻辑,所以最终经过排列组合我们能够预测处x,y的情况
x | y |
---|---|
1 | 1 |
0 | 1 |
1 | 0 |
- 按照我们的理解应该是不可能出现x,y同时为0 的情况,因为x,y赋值之前a,b至少会出现一个进行赋值的,所以我们代码每次循环结束之前都会进行判定x,y同时为0 的情况。上述代码如果不出现x,y同时为0的情况就会一直执行。大家根据自己电脑算力情况估算时间。相信我让程序一直执行下去肯定会在中途结束的。
- 途中就是我自己计算出来的次数,循环253449次终于出现x,y同时为0的情况了,但是w h y. , 理论上是不可能出现同时为0的情况的。这就牵扯出本章节的主题---指令重排序
- 感兴趣的同学可以在a,b,x,y之前加上volatile修饰符。在运行下程序看看程序还会不会中途停止了。我这里测试了下不加volatile的最多10分钟就停止了,加上volatile修饰后从中午12点开始跑到第二天中午12点程序都没有停止。这应该不用我多说了吧。我们可以得出的结论是volatile禁止指令重排序。
- 那么Java中为什么会出现指令重排序呢?指令重排序又是啥?指令重排序能带来啥?
- 之前我有提到在多线程运行情况下不可能从一开始到结束的,中间肯定会被CPU调度由别的线程来执行的。线程的不断切换带来一个问题,A线程执行一半切换到线程B,等再次切换到线程A时CPU是如何知道A线程执行到什么地步的。而且CPU切换时不时随心所欲的切换的?
- 首先CPU虽然是任务调度但并不是随心所欲,首先Java最终是汇编指令。CPU在怎么切换线程也必须等待线程一个指令执行完才可以。所以说指令是具有整体性的。指令要么执行要么不执行,不存在执行一半。然后在JVM中有个程序计数器模块。这个模块就是记录每个线程执行到第几行指令了。等待CPU切换过来可以继续之前的指令所在处继续往下执行
- 我们很容易看的出来在17中是多条指令。而且a=1, x=b两者没有绝对关联关系所以在指令执行上有可能x=b先执行。所以就会产生x,y同时为0的情况。
- 到这里我们通过两个不同的案例分别分析了volatile的作用。内存可见保证线程数据最终一致性;禁止指令重排序避免数据错乱。
synchronized
public class SimpleTest {
public static void main(String[] args) {
synchronized (SimpleTest.class) {
System.out.println("hello world");
}
}
}
- 上面是个很简单的
synchronized
的使用。我们看看对应的字节码部分
synchronized
实际上字节码层面就是MONITORENTER
和MONITOREXIT
; 值得注意的是在MONITOR 指令之前我们都一个指令在操作栈上数据。这就是我们synchronized
括号后面的内容
- 稍微改动我们就能够发现,操作对象就不同了。从栈的角度我们也好理解上锁的概念了吧。
- 往往提到并发
synchronized
这个关键字是挥之不去的,上面我们攻克了volatile这个关键字。剩下的就是synchronized了。首先我们先来看看概念 synchronized
作用就是上锁,在多线程开发中被synchronized
修饰的代码同一时间内只会有一个线程拥有,并且被修饰的代码是无法被打断的。保证了多线程下的串行化执行。这里我们可以理解成原子性- 另外
synchronized
和Lock
的区别这里我们也不做展开。我们大概总结下就是前者是Java语言关键字,后者是java 类;另外前者原生锁后者需要自己上锁和解锁。 synchronized
关键字修饰的对象有以下几种情况
修饰对象 | 作用 |
---|---|
代码块 | 被修饰代码块是同步代码块。代码块锁范围取决于修饰对象 |
普通方法 | 同步方法,相当于锁住调用该方法的对象 |
静态方法 | 同步方法,相当于锁住调用该方法的Class对象 |
修饰类 | 锁住Class对象 |
修饰对象 | 锁住java对象 |
- 上面的总结还是比较抽象的。其实
synchronized
只有两种情况,锁的范围不同 - 一个是锁住Class对象。一个是锁住java对象。
预告
- 后面我们继续说明锁对象问题,锁class 问题,同时通过单列模式来进行volatile关键字说明
转载自:https://juejin.cn/post/7066968861215555598