引言
在我们开始写正文之前,我们先看几行代码,各位读者是否能看出问题呢?
第一段:
public static void main(String[] args) { int v1 = 1073741827; int v2 = 1431655768; System.out.println(v1 + v2); }
在各位读者看来,应该输出什么呢?
第二段
public class ThreadTestService { int a = 0; boolean flag = false; public void writer() { a = 1; flag = true; System.out.println(flag); } public void reader() { if (flag) { int i = a; System.out.println(i); //..... } else { System.out.println("无"); } } }
假设有两个线程A和B,A线程首先执行writer方法,随后B线程接着执行reader方法,在线程B执行操作时,能否看到线程A操作1对共享变量a的写入呢?
下面我们带着上面两个问题,开始本篇博客的内容
JMM概述:
JMM全称是Java Memory Model (java 内存模型),JMM的关键技术点都是围绕多线程的原子性、可见性、有序性建立的,这也是解决多线程并行机制的环境下,定义出定义一种规则,意在保证多个线程可以有效的、正确的协同工作。
在JAVA中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量,方法定义参数和异常处理参数不会再线程之间共享,他们不会存在内存可见问题,也不受内存模型的影响。
三要素——原子性
原子性是指一个操作是不可中断的,即使是在多个线程一起执行的情况下,一个操作一旦 开始执行,就不会受到其他线程的干扰。
比如,有两个线程同时对一个静态全局变量int num进行赋值,线程A给他赋值为1,线程B给他赋值为2,那么不管这两个线程以何种方式何种步调去执行,num的值最终要么是1要么是2,线程A和线程B在赋值操作期间,是不可能受到对方干扰的,这就是原子性的一个特点——不可被中断。
但如果我们不使用int类型而是用long类型的话,可能就会出现差池了,因为对于32位系统来说,long类型数据的写入不是原子性的(因为long有64位),也就是说,如果两个线程在32位操作系统下同时对一个long类型的数据进行同步操作,那么线程之间的数据操作可能是有干扰的。
三要素——可见性(visbility)
可见性是指在多线程环境下,当一个线程修改了某个 共享变量的值以后,其他线程是否能够立即知道这个修改, 显然,对于串行线程 来说,可见性问题 是不存在的,但是这个问题在并行线程中就会存在了。
如上图,在CPU1和CPU2上个运行一个线程,他们共享变量t,由于编译器优化 或者硬件优化的缘故,在CPU1上的线程将变量t进行了优化,将其缓存在 cache中一个副本,在这个情况下,如果在CPU2的线程修改了t的实际值,那么CPU1上的线程可能无法意识到这个改动,依然会读取cache中的的数据,因此这就产生了可见性问题。
三要素——有序性
有序性问题是比较难理解的一个 问题,对于一个线程执行的代码来说,我们 总习惯性的认为代码总是从前往后依次执行,在单线程环境下确实如此,但是在多线程并发环境下就不见得了,程序的执行可能会出现乱序,给人的感觉就是写在前面的代码,却在后面执行了,造成 这种现象的原因就是:指令重排,重排后的指令未必与原指令顺序一致。
那么这里就会出现一个疑问,为什么会进行指令重排呢,我想大部分读者都可能知道原因,那就是提高性能!!
Happen-before规则
虽然指令重排可以提高性能,但是并不是所有的指令都可以进行重排,重排的前提必须是保证程序能输出正确的结果。下面总结一些规则:
程序执行原则:一个线程内保证语义的串行性。
volatile规则:volatile变量的写操作,必须在读操作前面执行,这保证了volatile变量的可见性。
锁规则:解锁操作必然发生随后的加锁前。
传递性:A先于B,B先于C,那么A必然先于C
线程的start方法先于它的每一个动作
线程的所有操作先于线程的终结 thread.join
线程的中断先于被 中断的线程代码
对象的构造函数执行,结束先于finalize()方法
解答疑惑
通过上面 基本内容的总结,我们现在回过头来看文章开始第二段代码在多线程下环境下出现的问题,就是因为程序在执行的过程 中发生了指令重排。
对于第一段代码,如果我们 在很短的时候内不能看出错误的地方 ,这说明我们基本知识不是很扎实,这段代码的问题是因为 两个数相加超过了int数据类型的范围,所以会出现负数,单独拿出来这个问题 我们可能会很快的想出答案,但是如果这样的代码隐晦的程序出现在我们的复杂的业务逻辑代码中会是什么后果呢?并且这种问题在 测试环境数据量小的情况下还不容易复现,这种没有异常抛出,但是给我们返回了一个错误的执行结果的问题,我们 称之为程序幽灵。
幽灵——并发下的ArrayList
我们都知道ArrayList是一个线程不安全的容器,如果在多线程中使用ArrayList,可能会导致程序出错,看下面代码:
package com.zqf.urgerobot.tools.service; import java.util.ArrayList; /** * @author zhenghao * @description: * @date 2020/7/239:41 */ public class ArrayListMultiThrea { static ArrayList<Integer> al = new ArrayList<>(); public static class addThread implements Runnable { @Override public void run() { for (int i = 0; i < 100000; i++) { al.add(i); } } } public static void main(String[] args) throws Exception { Thread t1 = new Thread(new addThread()); Thread t2 = new Thread(new addThread()); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(al.size()); } }
t1 t2两个线程同时操作同时向一个ArrayList中添加容器。他们各添加10万个元素,因此我们期望的时候最后list中有20万个元素,但是如果我们运行这段代码会有三种结果。
第一、程序一切正常,最后有20万个元素
第二、程序抛出异常
这是 因为在ArrayList在扩容过程中,内部一致性被破坏,但由于没有锁的保护,另外一个线程访问到了不一致的内部状态,导致出现越界问题。
第三、出现了一个非常隐蔽的错误,比如打印的值小于20万。
这是由于多线程访问冲突,使得保存容器大小的变量被多线程不正常访问,同时两个线程也对ArrayList中的同一个位置进行赋值导致的。如果出现这种问题,那么很不幸,你就得到了一个 没有错误提示的错误。并且,他们未必是可以复现的。
幽灵——并发下的HashMap
这个问题我们前面博文已经写过对应的文章《多线程环境下HashMap导致CPU100%》,这里不再啰嗦。
小结
在上文中我们介绍了JMM和一些我们常见的问题,这些问题虽然比较简单,但是当它们混淆在我们业务代码中,一旦线上 发生问题就会我们束手无策,在下一篇博客中介绍我们的送命代码——双重检查锁定及延迟初始化!