对一些初学或者老的java程序员,都知道在多线程下访问一个共享变量是非线程安全,所谓的非线程安全,意思是线程a对 共享变量 的修改 对于线程b可能是不可见的(这句话听不懂没关系,看完全文,回头再来看,就会发现这句话豁然明朗)。直接上例子
// 建议可以代码截图,然后配合文字一起看
public class Test {
static long a= 0; //共享变量
// volatile static long a= 0;
public static void main(String[] args) throws Exception {
Thread work = new Thread(() -> {
while (a== 0) {
}
// System.out.println("work finish");
});
work.start(); //工作线程开启
Thread.sleep(100);
a= 7;
work.join();
}
}
这个例子我没有用到 去打印东西,我注解掉了这个打印语句,为什么呢?因为有些同学可能 学过一点多线程的东西,知道 println里面的有同步代码块,当却不知道同步代码块的 细节,只知道同步代码块内能读取到 最新的共享变量的信息,或者同步代码块能加读写屏障,为了不考虑这些细节,我去掉了打印语句。当运行程序发现,程序一直停不下来,分析程序:main这个主线程是肯定停止了,那么肯定就是work线程无法停下来,为什么在我修改了sum的值后,work线程在循环判断的时候不结束这个死循环?有的同学可能给共享变量添加了volatile关键字发现了程序可以停止,但是本文在这里先不提这个关键字,而只是分析这个程序停止不下来的原理.
在把main线程这个睡眠时间设定为 1或0 毫秒,发现程序能正常运行结束
- sleep(1) 程序能停, work线程 能感受到main线程对sum的更改
- sleep(100)程序不能停,work线程不能感受到main线程对sum的更改
- sleep(1) JVM对work线程里执行的代码块还没正式开启 JIT优化
- sleep(100) JVM对work线程里执行的代码块 正式开启 JIT优化
“哎呀,你是不是在乱讲啊!你怎么知道这个优化开启没有?”有的同学可能要这样问了。通过添加虚拟机参数 运行程序 -Xint ,设置JVM执行引擎为 纯解释执行,这个时候发现即使是 main 睡眠10000秒,程序照样能正常结束,意思就是 在 JVM执行引擎为 纯解释执行时 , 共享变量在多线程之间是可见的,即main线程对 共享变量的更改对于 work线程来说是可见的,所以这个时候work线程读取到sum为 7,不满足循环的判断条件,work线程跳出循环,程序结束。
在JVM执行引擎 有3种模式调节——纯解释,纯编译,混合模式,默认在64为主机是混合模式。所以刚刚出现 睡1毫秒能停止程序,是work线程执行的代码还没被 JIT正式的解释执行,而睡100秒,work线程执行的循环代码块已经完全被 JIT解释执行(由于JIT有c1和c2两种执行引擎,而且在默认的分层编译的 模式下,一段代码块会被以多种方式编译,在这里我们假设JIT的编译是原子性的,要不就没有对代码编译,要不就是开启了对代码的编译并且执行)。
结论:共享变量在线程之间的 不可见性 是JIT编译器开启后 带来的副作用。
如果有兴趣了解更深入的原理,可以往下看~
按照JMM的抽象,首先共享变量 a 得在主存有一份,然后在work线程的工作内存和主线程的工作内存有共享变量a的副本,每个线程只能访问自己的工作内存。模型抽象为下图,建议snipaste贴图后,观看下文的文章描述
- main :main线程 简写 (work同理)
- 内存1:工作内存1的简写 (内存2同理)
- a1 :副本a1的简写
(在没有JIT优化的前提下)例如:work读取 a1,完成更改操作,返还值给 内存1, 内存1 接收到work返还的值完成对 a1的更改,并且把 a1的值刷新给 a。主存再把刷新后的 a 刷给 内存2的 a2,所以main就感受到了其他线程对 共享变量a的修改。
public class Test {
static long a = 0;//共享变量
public static void main(String[] args) throws Exception {
Thread work = new Thread(() ->a++);work.start();
a++;
}
}
所以上面两个线程在给一个共享变量各a++,a的最后结果可能是1,也可能是2。但是2这个结果,在上面的代码手动去运行出来,机率太小,所以我设置了测试程序如下:
// 添加虚拟机参数 ,关闭JIT,为了实验的严谨性,
// VM参数: -Xint
public class Test {
static long a = 0;
public static void main(String[] args) throws Exception {
while (true){
Thread work = new Thread(() ->a++);work.start();
a++; if (a==2) break;
work.join();a = 0; //重置
}
System.out.println("结束");
}
}
在由JIT优化时,JIT对代码进行优化(反复执行的热点代码转化为 机器码),而且重点是,对这段代码内用到的共享变量,进行优化。代码的优化好理解,如字节码编译存储为机器码等,机器码肯定是比字节码要运行得更快的。对共享变量的优化 举个例子(下面2张图,建议先把图贴在屏幕,好理解文字),JIT给 work线程优化了一段代码(这里指的就是while循环块),代码内引用到了共享变量a,这个共享变量在work线程对应的工作内存肯定是有个副本值a1,本来主存 可以给这个 副本更新,只要main线程更改a2,然后同步到 主存a,主存就可以同步更新a1。但是,在work线程 被JIT开启了优化,在这个时间点开始,主存不能更新内存1 的副本a1(但是反过来 内存1 还是能更新主存的共享变量a),如下图
// 就是上面第一次使用的代码,为了在这方便看
public class Test {
static long a= 0; //共享变量
public static void main(String[] args) throws Exception {
Thread work = new Thread(() -> {
while (a== 0) {
}
});
work.start(); //工作线程开启
Thread.sleep(100);
a= 7;
work.join();
}
}
再来做个实验来验证这个结论,代码如下
//先把代码截图,然后配合下面的文字一起看
// VM参数: 将vm参数清空,这里我们需要测试JIT,所以必须清空
public class Test {
static long a = 0; //共享变量
static long h = 0; //共享变量
public static void main(String[] args) throws Exception {
new Thread(() -> {
while (a <= 0x7ffffffffl) {h = a;} }).start();
Thread work = new Thread(() -> {
while (a <= 0x7ffffffffl) {a++;}});
work.start();
Thread.sleep(1000);
//睡眠一秒可以确保2个线程都已经被 JIT激进优化
//一般这种循环体 几毫秒 JIT就可以开始优化
System.out.println(h);//127914
System.out.println(a);//1551985624
Thread.sleep(100);
System.out.println(h);//127914
System.out.println(a);//1689629292
work.join(); // work 结束要大概10几秒,所以在这等待
System.out.println("work线程结束");
}
}
首先,将vm参数清空,虚拟机以混合编译模式启动(对热点代码会先解释执行再优化为JIT编译执行),这样JIT才会 优化代码。
匿名的线程和主线程,在一秒后,都被JIT优化执行,此时,work线程一直在更新自己工作内存的 副本a,然后这个副本a,会同步到主存 a,主存a 同步给没有被JIT优化的 main线程的副本a,所以在main线程能打印出不同的a值。
匿名线程在1秒内的某个时刻就被JIT优化,在这个时刻,主存的共享变量a,就不能同步到这个匿名线程,在这个时刻匿名线程的副本a就不会再变化 ,所以给自己工作内存的副本h的赋值,一直都是 自己工作内存里那个不变的副本a。所以打印的h值也就是在匿名线程被JIT优化的那个时刻,最后一次与主存里的a值保持同步的值。
这个程序是不能正常结束的,因为匿名线程的副本a,没办法被自己更新,也无法被主存同步,work线程的副本a也是不能被主存同步,但是他每次循环都会更改自己的副本a值,在循环一定次数,就会跳出循环。
在来看一个实验
//先把代码截图,然后配合下面的文字一起看
// VM参数: 将vm参数清空,这里我们需要测试JIT,所以必须清空
public class Test {
static long a= 0; //共享变量
static long h= 0; //共享变量
public static void main(String[] args) throws Exception {
Thread work = new Thread(() -> {
while (a <= 0x7ffffffffl) {a++;}
}); work.start();
Thread.sleep(1000); // JIT激进优化开启
new Thread(() -> {
long now = a;
long now1 = a;
while (now == now1) { //确定 a的更新在这个新开启的线程是可见的
now = a;
now1 = a;
}
System.out.println("内线程结束");
}).start();
long var = a;
long var1 = a;
while (var == var1) {//确定 a的更新在main线程是可见的
var = a;
var1 = a;
}
System.out.println("对外可见");
work.join();
System.out.println("结束!");
用刚刚的逻辑,1秒后,work线程被JIT优化,work线程是时刻在 给自己工作内存的副本a 进行更新,而且更新后的工作内存会把更新的副本a 同步给主存里的a(只是主存不能再给 work工作内存的副本a 赋值),这个时候启动一个匿名的线程,由于刚刚启动,还没被JIT优化,所以匿名线程的副本a 是和主存保持同步,所以在2次读取自己工作内存的副本a时,2次读取结果可能不同,应为work线程一直在给 主存的a更新,而匿名线程的副本a是和主存保持同步,所以2次读取结果是可能不一样,匿名线程能正常结束
而main线程虽然是早就创建,但是这段代码块是需要循环执行很多次才能被优化,没有被JIT优化,main线程的副本a就是和主存保持同步,所以也能这个循环能停止。
而work线程虽然它的副本a是不能被主存赋值,但是它自己是能更新自己的,所以在一定时间后,(我是在10几秒后),work也结束,整个程序终止!
看到下面这幅图,
这里没有提到缓存一致性,JIT如何分层编译,读写锁的内容等一些底层的内容。我绝对没有必要,而且现在的结论已经够用了。
如果还有不理解,可以尽量提,可以互相交流。