Java线程安全问题的本质
⽬录:
线程安全问题的本质
出现线程安全的问题本质是因为:
主内存和⼯作内存数据不⼀致性以及编译器重排序导致。
所以理解上述两个问题的核⼼,对认知多线程的问题则具有很⾼的意义;
简单理解CPU
CPU除了控制器、运算器等器件还有⼀个重要的部件就是寄存器。其中寄存器的作⽤就是进⾏数据的临时存储。寄存器是cpu直接访问和处理的数据,是⼀个临时放数据的空间。
CPU读取指令是通过内存去读取的,读⼀条指令放到CPU 寄存器中,然后CPU去执⾏处理;所以从内存中去读取指令的速度快慢就决定了这个CPU的执⾏速度快慢。
⽆论我们的CPU怎么去升级,如果从内存读取数据的速率问题不解决的话,其CPU的执⾏性能也不会得到
多⼤的提升。
为了弥补这个问题,在CPU中添加了⾼速缓存的机制,如ARM A11的处理器,它的1级缓存中的容量是64KB,2级缓存中的容量是8M。
通过增加CPU⾼速缓存的机制,如果寄存器要取内存中同⼀内存位置中的数据,则直接从⾼速缓存中读取即可,⽆需直接从主内存中进⾏读取,以此弥补服务器内存读写速度的效率问题,提⾼CPU的执⾏速率;
经过简化后的CPU与内存操作的简易图,如下图所⽰:
JVM虚拟机类⽐于操作系统(可见性)
JVM虚拟计算机平台就类似于⼀个操作系统的⾓⾊,所以在具体实现上JVM虚拟机也的确是借鉴了很多操作系统的特点;
CPU在计算的时候,并不总是从内存读取数据,它的数据读取顺序优先级是:寄存器-⾼速缓存-内存; JAVA中线程的⼯作空间(working memory)就是CPU的寄存器和⾼速缓存的抽象描述,
Java内存模型中规定了所有的类变量都存储在主内存中,每条线程还有⾃⼰的⼯作内存(类⽐于CPU的⾼速缓存),线程的⼯作内存中保存了该线程使⽤到的变量到主内存副本拷贝,
线程对变量的所有操作(读取、赋值)都必须在⼯作内存中进⾏,⽽不能直接读写主内存中的变量,操作完成后再将变量写回主内存。不同线程之间⽆法直接访问对⽅⼯作内存中的变量,
线程间变量值的传递均需要在主内存来完成。基本关系如下图:
注意:这⾥的Java内存模型,主内存、⼯作内存是⼀个抽象的概念与Java内存区域模型的Java堆、栈、⽅法区本质上不是同⼀层次的内存划分。
尽管Java内存模型和Java内存区域模型的划分不是⼀个层次的划分。“但是如果将Java内存模型中的⼯作内存和主内存⼀定要落实到对应的Java内存区域模型中时”,
Java⼯作内存⼜可以被称作为栈,⽽主内存则对应的是Java内存区域模型中的堆;所以后续提到的线程⼯作内存的概念时,读者也可以直接理解为线程的栈空间即可,⽽主内存则直接对应到堆空间上即可;
Java 内存模型对应英⽂为(Java Memory Model 简称为 JMM)之所以说JMM和Java内存区域模型并⾮⼀个层次的划分的原因是:JMM 本⾝是⼀个抽象的概念,并不具体存在,它所描述的是⼀组规范或者说规则,通过这种规则定义了
Java程序中各个变量的访问⽅式。请仔细理解⼀下这句话“JMM的规则定义了 Java程序中各个变量的访问⽅式”;
那么在JAVA程序中,各个变量的访问⽅式是什么样的?在JAVA中我们常知的变量定义有:类的实例变量,静态变量;那么这⼏种变量的访问规则是什么样的?
在JMM的规范中定义如下:在Java中所有的变量都是存储在主内存堆中,主内存是共享内存的区域,所有的线程都可以访问。
但线程对变量的操作(读取,赋值)则必须在线程的⼯作空间(栈)中执⾏;所以在线程的执⾏过程中,⾸先需要将变量从主内存中拷贝到⾃⼰的⼯作空间中,然后对变量进⾏操作,
操作完成后,再将变量写回主内存中。所以线程的⼯作空间中是不能直接操作主内存中的变量,⽽是操
作的是主内存中的变量副本的拷贝。因此各个线程之间是⽆法访问对⽅的⼯作内存的,线程的通信传值则必须通过主内存来进⾏完成;JMM的简要访问过程如下图:
Question:看到这⾥,应该会有⼀个疑问,上⾯所提到的JAVA中我们常知的变量有:“类的实例变量,
静态变量”;难道⽅法内所定义的本地变量就不属于变量吗?就不受JMM的规范要求吗?
Answer:⽅法内所定义的本地变量当然也属于JAVA中变量的⼀种,但是,⽅法内所定义的本地变量如果是基本数据类型则是不存储在Java 的主内存堆中的,⽽是直接存储在Java的线程栈中;所以在线程执⾏对应⽅法时,
直接从栈帧的局部变量表中直接使⽤当前该线程栈中的变量即可。由于本⾝栈就是线程的私有内存空间,所以不存在⽅法内本地变量共享内存区域的问题。⾃然也就⽆需受到JMM的变量访问规则的要求。
注意,严谨起见还是再说明⼀下:
根据JAVA虚拟机的规范,⽅法内的本地变量定义如果是基本数据类型(byte,short,int,long,double,boolean)则是直接存储在线程栈帧的,所以⽆需收到JMM的变量访问规范要求。
但是,如果⽅法内的本地变量定义是对象的引⽤类型,那么该变量的引⽤会存储在栈帧中,⽽对象的实例则还是存储在主内存中的。
关于Java栈中的⽣命周期及栈帧中所存储局部变量表所对应的内容,可以参考之前的这个⽂章:
通过如上的介绍,应该对JMM的规范有了⼀定的了解,下⾯举⼀个⼩的例⼦:
public class JavaBasicTest {
private Integer num = 0;
public static void main(String[] args) {
JavaBasicTest javaBasicTest = new JavaBasicTest();
new Thread(() -> javaBasicTest.numPlus()).start();
java系统变量设置new Thread(() -> javaBasicTest.numPlus()).start();
}
public void numPlus() {
num = num + 1;
System.out.println(num);
}
}
想把⼀个简单的东西⽤语⾔说明清楚还真不是很容易。
此处详细说明⼀下:
1、我们上述测试类中所定义的类变量: private Integer num = 0; 就是存在于我们的主内存(堆)中;
2、线程在执⾏到numPlus()⽅法的时候,由于⽅法内存在对实例变量num的引⽤,所以此处⽅法内num的值实际是主内存的值的拷贝;
3、所以当第⼀个线程执⾏到:num = num+1 时,此时的num结果为2;操作完成后,重新写⼊主内存中;
4、然后第⼆个线程开始执⾏numPlus()⽅法,此时⽅法内num的值是主内存的拷贝,也就是 num = 2;然后 num=num+1;此时第⼆个线程执⾏完毕后,num结果为3;
OK,以上是正常执⾏的情况下;实际执⾏过程中,由于线程1和线程2是并⾏执⾏的,那么可能存在线程1在执⾏完以后,num的结果还未实时写到主内存时,线程2开始执⾏num = num+1;
此时由于线程1的执⾏结果num=1,并未实时写到主内存中,所以此时线程2所拿到的主内存的拷贝num还是为0,然后执⾏num =num+1;线程2执⾏完毕;此时num值仍然为1;
这就是⼀个很典型的!线程并发所引起的数据异常问题;我们通常把这种问题,称作为:数据的不可见性所导致的并发问题;
那么想要解决上述的问题,其实也很简单:
1、线程在执⾏num =num+1时,此时线程不进⾏num变量的主内存拷贝,⽽是直接读取并修改主内存的值;(可以想象,由于没有使⽤到线程的⼯作空间,⽽是直接操作的主内存进⾏的操作,所以性能上会有细微的影响,⽽这个影响的范围就是和主内存的读写速率直接挂钩)
2、在执⾏numPlus()⽅法时,加⼀把锁就可以;如:线程1执⾏numPlus()时加锁,⽽此时线程2由于没有获取到锁,所以只能等待线程1执⾏完毕后,才能获取锁,并执⾏numPlus()的⽅法,所以,此处不存在并发修改的问题,也就⾃然不会出现数据可见性⽽引起的并发问题;
所以!看!单纯的⼀个数据可见性的问题解决,就引申出了JAVA中多线程的两种玩法,⼀种是不加锁的案例,另外⼀种是加锁的案例;
不加锁的案例在JAVA中的对应玩法则是:volatile,⽽加锁的案例在JAVA中对应关键词则是:Synchroni
ze 以及 Lock;
⽽Lock则⼜引申出了⼀系列的Java并发包下的线程玩法,分别是:ReentrantLock,AQS,CountDownLatch,ReadWriteLock 以及共享锁Semaphore 等⼀系列针对锁的优化在不同场景下的玩法;
这些具体的Jdk⾃⾝所提供的的各种线程的操作,我们后续再聊,然后接着向下看。
重排序(有序性)
在执⾏程序时,为了提⾼性能,编译器和处理器常常会对指令进⾏重排序。⼀般重排序可以分为如下三种:
指令并⾏重排的定义:现代处理器采⽤了指令级并⾏技术来将多条指令重叠执⾏。如果不存在数据依赖性(即后⼀个执⾏的语句⽆需依赖前⾯执⾏的语句的结果),
处理器可以改变语句对应的机器指令的执⾏顺序;指令重排只会保证单线程中串⾏语义的执⾏的⼀致性,但并不会关⼼多线程间的语义⼀致性;
举例如下:
public class Singleton {
public static  Singleton singleton;
/**
* 构造函数私有,禁⽌外部实例化
*/
private Singleton() {};
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
如上,⼀个简单的单例模式,按照对象的构造过程,实例化⼀个对象可以分为三个步骤(指令):
1、分配内存空间。
2、初始化对象。
3、将内存空间的地址赋值给对应的引⽤。
但是由于操作系统可以对指令进⾏重排序,所以上⾯的过程也可能变为如下的过程:
1、分配内存空间。
2、将内存空间的地址赋值给对应的引⽤。
3、初始化对象。
指令重排后,在单线程串⾏执⾏的情况下,不会有任何问题;但是
如果出现并发访问getInstance()⽅法时,则可能会出现,线程⼆判断singleton是否为空,此时由于当前该singleton已经分配了内存地址,但其实并没有初始化对象,
则会导致return ⼀个未初始化的对象引⽤暴露出来,以此可能会出现⼀些不可预料的代码异常;
代码优化后的结果如下:
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
JMM并⾮在任何情况下都会进⾏重排序,⽐如Java编译器在⽣成指令序列的时候会禁⽌特定类型的处理器&编译器进⾏重排序的处理,
并且JMM的重排序优化需遵循数据的依赖性(如果两个操作同时访问⼀个变量,构成数据上下⽂依赖,则不会改变数据依赖关系的两个操作的执⾏顺序),
以及happens-before的原则,并⾮随意的进⾏指令重排序;具体的重排序时需遵循的排序规则,后续给出对应的参考链接,感兴趣的可以直接参考下;
不过,需要明确的⼀个重排序原则是:⽆论重排序如果优化,都是会保证单线程中串⾏语义执⾏的⼀致性的,但是并不会保证多线程的语义⼀致性;
从上述的举例也可以看出,由于涉及到指令重排的情况下,是可以保证串⾏语义的⼀致性的,所以如果涉及到多线程的语义⼀致性问题,⽽导致的并发异常的问题,
那么⼤概率,此时多线程并发的问题是和变量的共享有关。所以也就和数据的可见性息息相关;所以在处理这类问题的情况下,直接站在数据可见性的⾓度来处理这类并发问题,⼀般情况下没有任何问题;⽬前还没有遇到过解决了并发的数据可见性问题后,
还由于排序性问题所引起的并发问题的案例,当然这只是站在我个⼈的经验⾓度给出的评估,如果有类似案例的话,欢迎交流下。
OK接着向下。
总结
多线程安全的三个核⼼原则分别是:可见性,有序性和原⼦性;
上⾯已经分别针对“可见性”和“有序性”分别做了详细说明和举例,对于原⼦性,简单说⼀下:
其实原⼦性是⼀个很简单的规则,⽐如: a = 2; 这是⼀个原⼦性的操作,直接把对应的2进⾏赋值给到 a这个变量;但是如果是 a = a + 2;
那么这就不是⼀个原⼦性的操作,因为 a = a + 2 涉及到两个步骤,a + 2 ,然后得到结果再赋值给对应的 a 变量;因为这个操作涉及到两个步骤所以并⾮原⼦化的操作;
那么对于这种⾮原⼦化的操作,在写代码coding的时候就需要注意下,是否会引起多线程异常的问题;
但是,注意的是,类似于上述 a = a + 2 的操作,⼀般情况下我们是在⼀个⽅法中直接定义并操作,那么此时是没有线程安全问题的,这个
也就是栈的作⽤,这个不多说了;
另外⼀个就是,如果 a 是直接定义的类实例变量,然后在⽅法中操作的时候,此时的原⼦性问题所引起的风险,这个则是在实际开发中需要考虑的问题。
不过对于像上述的 a = a + 2 的这种⾮原⼦化操作所引起的线程异常的情况,在JAVA中也有提供对应的⼀系列的(⾮加锁)⽅案(针对上述的场景直接加锁是⼤可不必的,浪费性能);
所以JDK中也提供了Unsafe类下的⼀系列 CAS 操作,如JDK Atomic包下的⼀系列现成类:
AtomicInteger,AtomicBoolean,AtomicReference,AtomicIntegerArray,AtomicStampedReference 等,都可以以⽆锁的⽅式直接解决上述的原⼦化所引起的
线程安全问题;关于Atomic包下的各种类的使⽤⽅式及实现原理,其实也⽐较简单,后续再详细写⽂章细聊。
OK,然后关于Java线程安全的问题,聊到这⾥,基本,也就结束了。陆陆续续写了也好多。
参考链接
⼀篇博客不可能说明所有问题,毕竟要紧扣主题,所以看到这⾥,如果对JMM的重排序的具体规则还想了解下对应的细节,则可以参考下如下链接:
&
关于Java栈中的⽣命周期及栈帧中所存储局部变量表所对应的内容,可以参考之前的这个⽂章:
最后的最后!如果想要更清楚的了解JVM及并发的相关知识,建议直接查看如下书籍,寻求答案:
《深⼊理解JVM虚拟机》
《Java⾼并发程序设计》
《Java并发编程的艺术》

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。