博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
Java多线程之如何写出线程安全的程序?
阅读量:2192 次
发布时间:2019-05-02

本文共 12022 字,大约阅读时间需要 40 分钟。

1 如何写出线程安全的程序?

1.1 这道题想考察什么?

答:(1)考察要点:●是否对线程安全有初步了解(初级);●是否对线程安全的产生原因有思考;是否知道final、volatile关键字的作用;是否清楚1.5之前JavaDCL为什么有缺陷(中级);●是否清楚的知道如何编写线程安全的程序;是否对hreadLocal的使用注意事项有认识(高级)

(2)题目剖析:●如何写出线程安全的程序?●什么是线程安全?如何实现线程安全?

2 什么是线程安全?

答:(1)本质是可变资源(数据)线程间共享的问题,关键点是:可变;共享。为什么没听说过进程安全,因为每个进程拥有独立的内存单元,而线程则共享内存资源。

(2)直接理解:线程安全是一个多线程环境下正确性的概念,也就是保证多线程环境下共享的、可变的资源(数据)的正确性。换个角度来看,如果资源是不可变,或者不共享的,也就不存在线程安全问题

3 Java内存模型(本文内容依赖于内存模型基础)

3.1 Java内存模型基础?

答:Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,Java内存模型定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程读/写共享变量的副本。 本地内存是JMM的一个概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下所示:

在这里插入图片描述
在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,他们不会有内存可见性的问题,也不会受到内存模型的影响。

3.1.2 线程之间是如何通信的?

答:如图所示,线程A与线程B要通信的话,必须经过两个步骤:(1)线程A把本地内存中更新过的共享变量刷新到主内存中;(2)线程B去主内存读取线程A更新过的共享变量。如图,线程A在执行时,把更新后的x值临时存放在本地内存中,当线程A和线程B需要通信时,线程A首先会把本地内存中修改后的x值刷新到主内存中,此时主内存的x值变为了1,随后,线程B去主内存读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

在这里插入图片描述

结论:这两个步骤的实质上是线程A再向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存的可见性保证。

3.1.3 线程之间是如何同步的?

答:同步是指程序中用于控制不同线程间操作发生相对顺序的机制

3.2 什么是Java内存模型中的顺序一致性?

答:(1)重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段

(2)顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。

3.2.1 什么是数据依赖性?

答:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下面这三种类型:

在这里插入图片描述

3.2.2 什么是as-if-serial语义?

答:as-if-serial语义的意思是:不管怎么重排序,(单线程)程序的执行结果都不能被改变编译器、runtime和处理器都必须遵守as-if-serial语义,不会对存在数据依赖关系的操作做重排序

但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。示例如下:

double a = 3.14;        // Adouble b = 1.0;         // Bdounle c = a * b * b;   // C

A和C之间存在依赖关系,B和C之间也存在依赖关系,因此在最终执行的指令序列内,C不能被重排序到A和B前,因为这样程序的结果会被改变,但是A和B之间没有依赖关系,编译器和处理器可以重排序A和B之间的执行顺序

在这里插入图片描述

3.2.3 happens-before和as-if-serial语义矛盾吗?

答:不矛盾。as-if-serial语义保证但线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序中的执行结果不被改变。目的,都是为了在不改变程序最终执行结果的前提下,尽可能的提高程序并发的执行度。

3.2.4 重排序对多线程的影响?

答:在多线程中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

3.3 Java内存模型中的happens-before

3.3.1 什么是 happens-before 关系?

答:happens-before 关系是用来描述和可见性相关问题的:如果第一个操作 happens-before 第二个操作(也可以描述为,第一个操作和第二个操作之间满足 happens-before 关系),那么我们就说第一个操作对于第二个操作一定是可见的,也就是第二个操作在执行时就一定能保证看见第一个操作执行的结果

3.3.2 happens-before 关系的规则和重排序冲突,为了满足 happens-before 关系,就不能重排序了?

答:否定的。只要重排序后的结果依然符合 happens-before 关系,也就是能保证可见性的话,那么就不会因此限制重排序的发生。一旦会改变最终执行结果,就必然禁止重排序。比如:单线程内,语句 1 在语句 2 的前面,所以根据“单线程规则”,语句 1 happens-before 语句 2,但是并不是说语句 1 一定要在语句 2 之前被执行。例如语句 1 修改的是变量 a 的值,而语句 2 的内容和变量 a 无关,那么语句 1 和语句 2 依然有可能被重排序。当然,如果语句 1 修改的是变量 a,而语句 2 正好是去读取变量 a 的值,那么语句 1 就一定会在语句 2 之前执行了。

3.3.3 单线程规则

答:在一个单独的线程中,按照程序代码的顺序,先执行的操作 happen-before 后执行的操作。这一个 happens-before 的规则非常重要,因为如果对于同一个线程内部而言,后面语句都不能保证可以看见前面的语句的执行结果的话,那会造成非常严重的后果,程序的逻辑性就无法保证了。

在这里插入图片描述

3.3.4 锁操作规则(synchronized 和 Lock 接口等)

答:如果操作 A 是解锁,而操作 B 是对同一个锁的加锁,那么 hb(A, B) 。正如下图所示:有线程 A 和线程 B 这两个线程。线程 A 在解锁之前的所有操作,对于线程 B 的对同一个锁的加锁之后的所有操作而言,都是可见的

在这里插入图片描述

3.3.5 volatile 变量规则

答:对一个 volatile 变量的写操作 happen-before 后面对该变量的读操作

如果变量被 volatile 修饰,那么每次修改之后,其他线程在读取这个变量的时候一定能读取到该变量最新的值。我们之前介绍过 volatile 关键字,知道它能保证可见性,而这正是由本条规则所规定的。

3.3.6 线程启动规则

答:Thread 对象的 start 方法 happen-before 此线程 run 方法中的每一个操作。如下图所示:

在这里插入图片描述
在图中的例子中,左侧区域是线程 A 启动了一个子线程 B,而右侧区域是子线程 B,那么子线程 B 在执行 run 方法里面的语句的时候,它一定能看到父线程在执行 threadB.start() 前的所有操作的结果。

3.3.7 线程 join 规则

答:join 可以让线程之间等待,假设线程 A 通过调用 threadB.start() 启动了一个新线程 B,然后调用 threadB.join() ,那么线程 A 将一直等待到线程 B 的 run 方法结束(不考虑中断等特殊情况),然后 join 方法才返回。在 join 方法返回后,线程 A 中的所有后续操作都可以看到线程 B 的 run 方法中执行的所有操作的结果,也就是线程 B 的 run 方法里面的操作 happens-before 线程 A 的 join 之后的语句。如下图所示:

在这里插入图片描述

3.3.8 中断规则

答:对线程 interrupt 方法的调用 happens-before 检测该线程的中断事件

如果一个线程被其他线程 interrupt,那么在检测中断时(比如调用 Thread.interrupted 或者 Thread.isInterrupted 方法)一定能看到此次中断的发生,不会发生检测结果不准的情况

4 如何编写线程安全的程序?如何保证线程安全?

答:优先考虑不共享资源,或者共享不可变资源;如果是必须共享可变资源,从 原子性、可见性、禁止重排序 三个角度去保证线程安全。

在这里插入图片描述
(1)原子性,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现;
(2)可见性,是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile 就是负责保证可见性的。
(3)有序性(禁止重排序),是保证线程内串行语义,避免指令重排等。

4.1 不共享资源

答:纯函数,也是可重入函数:通过参数传入值,经过运算后返回结果,中间不会涉及任何外部内存的访问或修改,没有副作用。对于这种函数是线程安全的,先天具有线程安全的优势。

在这里插入图片描述

4.1.1 ThreadLocal是用来解决共享资源的多线程访问的问题吗?

答:不是,ThreadLocal并不是用来解决共享资源问题的。虽然ThreadLocal确实可以用于解决多线程情况下的线程安全问题,但其资源并不是共享的,而是每个线程独享的ThreadLocal保存每个线程独享的对象时,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本,而不会影响其他线程的副本,确保了线程安全

在这里插入图片描述

4.1.2 ThreadLocal的使用建议(详细的ThreadLocal前往)

答:(1)声明为全局静态final成员:在1个线程有1个ThreadLocal就够了,没必要创建多个。因为设置value时,以ThreadLocal为key,内容为value。如果创建了多个ThreadLocal,变换了引用,永远都找不着1个ThreadLocal对应的value

(2)避免存储大量对象:因为ThreadLocalMap解决hash冲突的方式采用的是开放定址法,适合对象较少的场景
(3)用完后及时移除对象因为线程的生命周期是很长的,如果线程迟迟不会终止,那么可能ThreadLocal以及它所对应的value早就不再有用了,也不会被回收。用下面这张图来看一下具体的引用链路(实线代表强引用,虚线代表弱引用):Thread Ref → Current Thread → ThreadLocalMap → Entry → Value → 可能泄漏的value实例。这条链路是随着线程的存在而一直存在的,如果线程执行耗时任务而不停止,那么当垃圾回收进行可达性分析的时候,这个ThreadLocal的Value就是可达的,所以不会被回收。但是与此同时可能我们已经完成了业务逻辑处理,不再需要这个Value了,此时也就发生了内存泄漏问题
在这里插入图片描述
在这种情况下,我们应该调用ThreadLocal的remove方法删除对应的value对象,保证它们都能够被正常的回收,避免内存泄漏

public void remove() {    ThreadLocalMap m = getMap(Thread.currentThread());    if (m != null) m.remove(this);}

4.2 共享不可变资源

答:final变量意味着这个变量一旦被赋值就不能被修改了,不可变的对象天生就是线程安全的,所以不需要我们额外进行同步等处理,这些开销是没有的

final int x;

4.3 共享可变资源-禁止重排序

答:使用final关键字;和使用volatile关键字

4.3.0 volatile适用于两种场景是什么?

答:(1)一种场景就是,某个共享变量自始至终只是被各个线程所赋值或读取,如果加了 volatile 就可以保证它的线程安全,因为不涉及读取之前的值,也不涉及在原来值的基础上再修改

(2)另一个场景:作为触发器,保证其他变量的可见性

4.3.1 final禁止重排序的原理?

答:final除了不可变性特征,还有个禁止重排序的特点

class FinalExample{	int i; // 普通变量	final int j; // final变量	static FinalExample obj;	public FinalExample(){ // 构造函数		i = 1; // 写普通域		j = 2; // 写final域	}	public static void writer(){ // 线程A写执行		obj = new FinalExample();	}	public static void read(){ // 线程B读执行		FinalExample fe = obj; // 读取包含final域对象的引用		int a = fe.i; // 读取普通变量		int b = fe.j; // 读取final变量	}}

在这里插入图片描述

(1)写final域规则:编译器会在 写final变量 之后,以及 构造函数执行结束 前,插入一个StoreStore屏障,确保 写final变量 构造函数执行结束 之前初始化,禁止处理器把 写final变量 重排序到 构造函数 之外。而普通域可能没有被正确初始化,因为 普通变量 的写入可能会重排序到 构造函数 外。
在这里插入图片描述
(2)读final域规则:编译器会在读final域操作的前面插入一个LoadIoad屏障,保证 读final变量 前的读取操作都执行完,主要是 读对象引用 操作,才会执行读final变量保证 先读对象引用,再改对象的读final变量。避免出现,普通变量的读取操作 可能排在 对象引用 的前面,变量都还没有被初始化。
在这里插入图片描述
(3)final引用不能从构造函数内“溢出”:在构造函数结束前,被构造对象的引用 不能为其他线程可见,因为此时的 final变量 可能还没有被初始化

public class FinalReferenceEscapeExample {	final int i;	static FinalReferenceEscapeExample obj;	public FinalReferenceEscapeExample () {   		i = 1;                          // 1写final域   	 	obj = this;                     // 2 this引用在此“逸出”	}	public static void writer() {   		 new FinalReferenceEscapeExample ();	}	public static void reader {    	if (obj != null) {              // 3       		int temp = obj.i;           //4    	}	}}

在这里插入图片描述

4.3.2 volatile禁止重排序的原理?

答:(1)volatile写-读建立的happens-before关系

private int count;  // 普通变量private volatile boolean falg; // volatile 修饰的变量public void writer(){
count = 1; // 1 falg = true; // 2}public void reader(){
if(falg) {
// 3 int sum = count + 1; // 4 }}

假设有两个线程:线程A调用读方法, 线程B调用写方法。根据happens-before规则,这个过程的建立分为三类,以及转换为图形化的表现形式如下:

①程序次序规则: 1 happens-before 2,3 happens-before 4
②volatile规则:2 happens-before 3;对一个volatile变量的写操作先行发生于后面对这个变量的读操作
③传递规则: 1 happens-before 4;
在这里插入图片描述
(2)volatile写的原理

①在每个volatile写之前插入一个StoreStore屏障,保证屏障上面写操作刷新到主内存先于下面的volatile写,避免volatile写与上面写操作重排序(让volatile写上面的写操作写完后,才执行volatile写,保证volatile写时获取的值是最新的)。

②在每个volatile写之后插入一个StoreLoad屏障,保证屏障上面的volatile写刷新到主内存先于下面可能有的写/读操作,避免volatile写与后面读/写操作重排序((让volatile写完后,才执行下面的读/写操作,保证下面的读/写操作获取的值是最新的))。

在这里插入图片描述
(3)volatile读的原理

在每个volatile读之后:

①插入一个LoadLoad屏障,保证屏障上面的volatile从主内存中读先于下面的读操作,避免volatile读与下面读操作重排序(让volatile读后,才执行下面的读操作,保证volatile读获取的值是最新的);

②再插入一个LoadStore屏障,保证屏障上面的volatile从主内存中读先于下面的写操作,避免volatile读与后面的写操作重排序(让volatile读后,才执行下面的些操作,保证volatile读获取的值是最新的)**。

在这里插入图片描述

小结:在每个volatile读之后插入一个LoadLoad屏障和一个LoadStore屏障,用来禁止把上面的volatile读与下面的读/写操作重排序,从而把该线程对应的本地内存置为无效。让屏障上面的 volatile从主内存中读后,才执行下面的读/写操作,保证volatile读获取的值是最新的,才存到工作内存中

(4)组合的例子

class volatile BarrlerExample {
int a volatile int v1 = 1: volatile int v2 = 2; vold readAndWrite() {
int i = v1; // 第一个volatile读 int j = v2; // 第二个volatile读 a = i + j; // 普通写 v1 = i + 1; // 第一个volatile写 v2 = j + 2; // 第二个volatile写 } ... // 其他方法}

在这里插入图片描述

4.4 共享可变资源-保证可见性

答:(1)使用volatile关键字(2)加锁,锁释放时会强制将缓存刷新到主内存。

在这里插入图片描述

4.4.1 volatile保证可见性的原理?

答:原理同 4.3.2 volatile禁止重排序的原理

volatile的保证可见性:对一个volatile变量的读,总是能看到(任意线程)对这个bolatile变量最后的写入:①当写一个volatile变量时,JMM会把该线程对应的更新的后的本地内存中的值强制刷新到主内存中②当读一个volatile变量时,JMM会把该线程对应的本地内存内存置为无效,然后线程会从主内存中读取最新的值到工作内存中

4.4.2 加锁保证可见性的原理?

答:加锁,锁释放时会强制将缓存刷新到主内存。锁除了让临界区代码互斥执行,还可以让释放锁的线程向同一个获取锁的线程发送消息。

(1)锁的释放-获取建立的happens-before关系

class MonitorExample {
int a = 0; public synchronized void writer() {
// 1 a++; // 2 } // 3 public synchronized void reader() {
// 4 int i = a; // 5 …… } // 6}

假设线程A执行writer0方法,随后线程B执行心ader0方法。根据happens-before规则, 这个过程包含的happens-before关系可以分为3类:

①根据程序次序规则,1 happens-before 2;2 happens-before 3;4 happens-before 5;5 happens-before 6;
②根据监视器锁规则,3 happens-before 4;
③根据happens-before的传递性,2 happens-before 5。
在这里插入图片描述
结论:线程A获取了锁,执行完相应代码,然后线程B才能去获取到锁,在线程B获取到锁的时候,线程A释放锁之前所有可见的共享变量都立刻对线程B可见

(2)锁的释放和获取的内存语义

结论:当线程释放锁时,JAVA内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中,当另一个线程获取锁的时候,JAVA内存模型会将该线程对应的本地内存设置为无效,所以该线程必须从主内存中读取共享变量,这就使得前一个线程在释放锁之后共享变量必然对另一个线程可见。如下图:

在这里插入图片描述

①线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了消息;②线程B获取一个锁,实质上是线程B接收到了之前某个线程发出的消息;③线程A释放锁,随后线程B获取这个锁,这个过程本质上是线程A通过主内存向线程B发送消息。

4.5 共享可变资源-保证操作原子性

答:(1)如何保证原子性,举例:a++不是原子性的

在这里插入图片描述
(2)保证原子性的方法
●加锁,保证操作的互斥性;
●使用CAS指令(如Unsafe. compareAndSwapInt );
●使用原子数值类型(如AtomicInteger );
●使用原子属性更新器(如AtomicReferenceFieldUpdater );

5 双重检查锁定与延迟初始化的原理?

答:双重检查锁定看起来似乎很完美,但这是一个错误的优化,在线程执行到4操作时,代码读取到的instance不为null时,instance引用的对象有可能还没有初始化完成

public class UnsafeLazyInitialization {                 // 1    private static Instance instance;                   // 2        public static Instance getInstance(){               // 3        if (instance == null){                          // 4 第一次加锁            synchronized(UnsafeLazyInitialization.class){     // 5 加锁                if(instance == null){                           // 6 第二次检查                    instance = new Instance;                    // 7 问题的根源出在这里                }                                       // 8            }                                           // 9        }                                               // 10        return instance;                                // 11    }}

5.1 问题的根源是什么?

答:(1)在前面的代码中的第7行,创建了一个对象。这行代码可以分解为如下的3行伪代码:

memory = allocate();        // 1:分配对象的内存空间ctorInstance(memory);       // 2:初始化对象instance = memory;          // 3:设置instance指向刚分配的内存地址

在上面的2和3操作之间,可能会被重排序,重排序之后的执行时序如下:

memory = allocate();        // 1:分配对象的内存空间instance = memory;          // 3:设置instance指向刚分配的内存地址(此时对象还没有被初始化)ctorInstance(memory);       // 2:初始化对象

(2)问题根源是:在分配内存地址的时候,对象有可能还没有被初始化。重排序不会改变程序的执行结果,换句话说:Java内存模型允许那些在单线程内,不会改变单线程执行结果的重排序。如下图:

在这里插入图片描述

(3)但是在多线程内,线程B将会看到一个还没有被初始化的对象,这就是问题根源。问题跟上面讲的一样,看下图就知道:

在这里插入图片描述
(4)在知晓了问题发生的根源后,我们可以想出两个办法来实现线程安全的延迟初始化:

①不允许2和3重排序;②允许2和3重排序,但不允许其他线程“看到”这个重排序。

5.2 基于volatie的解决方案:不允许2和3重排序

答: 把instance声明为volatile类型 private volatile static Instance instance,通过禁止重排序来保证线程安全的延迟初始化

public class UnsafeLazyInitialization {                 // 1    private volatile static Instance instance;          // 2        public static Instance getInstance(){               // 3        if (instance == null){                          // 4 第一次加锁            synchronized(UnsafeLazyInitialization.class){     // 5 加锁                if(instance == null){                           // 6 第二次检查                    instance = new Instance;                    // 7 问题的根源出在这里                }                                       // 8            }                                           // 9        }                                               // 10        return instance;                                // 11    }}

在这里插入图片描述

5.3 基于类初始化的解决方案:允许2和3重排序,但不允许其他线程“看到”这个重排序

答:JVM 在类的初始化阶段(即在Class 被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化。基于这个特性,可以实现另一种线程安全的延迟初始化方案,实质是:允许“问题的根源”的三行伪代码中的 2 和 3 重排序,只允许构造线程(这里指线程A)初始化构造函数,但不允许非构造线程(这里指线程B)“看到”这个重排序

public class InstanceFactory {    private static class InstanceHolder {        public static Instance instance = new Instance();    }    public static Instance getInstance() {        return InstanceHolder.instance ;  // 这里将导致 InstanceHolder 类被初始化     }}

假设两个线程并发执行 getInstance(),下面是执行的示意图:

在这里插入图片描述

转载地址:http://smcub.baihongyu.com/

你可能感兴趣的文章
几个基本的 Sql Plus 命令 和 例子
查看>>
PLSQL单行函数和组函数详解
查看>>
Oracle PL/SQL语言初级教程之异常处理
查看>>
Oracle PL/SQL语言初级教程之游标
查看>>
Oracle PL/SQL语言初级教程之操作和控制语言
查看>>
Oracle PL/SQL语言初级教程之过程和函数
查看>>
Oracle PL/SQL语言初级教程之表和视图
查看>>
Oracle PL/SQL语言初级教程之完整性约束
查看>>
PL/SQL学习笔记
查看>>
如何分析SQL语句
查看>>
结构化查询语言(SQL)原理
查看>>
SQL教程之嵌套SELECT语句
查看>>
日本語の記号の読み方
查看>>
计算机英语编程中一些单词
查看>>
JavaScript 经典例子
查看>>
判断数据的JS代码
查看>>
js按键事件说明
查看>>
AJAX 初次体验!推荐刚学看这个满好的!
查看>>
AJAX 设计制作 在公司弄的 非得要做出这个养的 真晕!
查看>>
Linux 查看文件大小
查看>>