《深入理解JVM》第12章 Java内存模型与线程

目录

第12章 Java内存模型与线程

12.3 Java内存模型

Java内存模型(JMM Java Memory Model):屏蔽掉各种硬件和操作系统的内存访问差异,以实现Java程序在各个平台下都能达到一致的内存访问效果。

(在此之前,C/C++直接使用物理硬件和操作系统的内存模型,会由于不同平台上内存模型的差异,导致程序在以套平台上并发完全正常,到另一平台上并发访问经常出错。)

经过长时间验证和修补,JDK1.5(实现了JSR-133)发布后,Java内存模型成熟和完善起来。

JSR-133:Java Memory Model and Thread Specification Revision Java内存模型和线程规范修订。

https://download.oracle.com/otndocs/jcp/memory_model-1.0-pfd-spec-oth-JSpec/

 

主内存和工作内存

Java内存模型规定了所有的变量都存储在主内存里。

每条线程有自己的工作内存,线程的工作内存保存了被该线程使用到的变量的主内存副本的拷贝。

线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。

不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存完成。

 

主内存和工作内存之间的交互协议

交互协议:一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存

Java内存模型定义了以下8种操作,虚拟机实现时需要保证每个操作都是原子的、不可再分的。

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态。

  • unclock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存,以便随后的load动作使用。

  • load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use(使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎。

  • assign(赋值):作用于工作内存的变量,把执行引擎接收到的值赋给工作内存的变量。

  • store(存储):作用于工作内存的变量,把工作内存中一个变量的值传送给主内存中,以便随后的write操作使用。

  • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。

操作的规定

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存中读取了但工作内存不接受,或者工作内存发起了回写但主内存不接受的情况出现。

  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步到主内存。

  • 不允许一个线程无原因地(没有发生任何assign操作)把数据从线程的工作内存同步到主内存。

  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量use、store操作之前,必须先执行过了assign和load操作。

  • 一个变量在同一时刻只允许一个线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unclock操作,变量才会被解锁。

  • 如果对一个变量执行lock操作,那么会清空工作内存次变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

  • 如果对一个变量执行lock操作,那么会清空工作内存次变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

  • 对一个变量执行unclock之前,必须先把此变量同步回主内存中(执行store、write操作)

 

这8中内存操作及上述规定,加上对volatile的特殊规定,完全确定了Java程序中哪些内存操作在并发下是安全的。

(最新的JSR-133文档,已经放弃采用这8种操作去定义Java内存模型访问协议了。

后面会介绍等效判断原则——先行发生原则,来确定一个访问在并发环境下是否安全。

 

1)对于volatile型变量的特殊规则

volatile的内存语义

1)保证可见性

对于volatile变量,当一个线程修改了这个变量的值,其他线程可以立刻得知新值

2)禁止指令重排序优化

Map configOptions;
//此变量必须定义为volatile
volatile boolean initialized = false

下面代码在线程A中执行,解析配置信息,解析配置后,将initialized设为true,通知其他线程

configOptions = processConfigOptions();
initialized = true;

下面代码在线程B中执行,等待nitialized设为true,对配置信息进行操作。

while (!initialized) {
   sleep();
}
doSomethingWithConfig();

如果nitialized变量不用volatile进行修饰,可能会由于重排序优化,导致线程A中initialized=true被提前执行,导致程序错误。

(重排序优化是机器级的优化,在此提前执行值对应的汇编代码被提前执行)

线程A  
initialized=true 
线程B 
while (!initialized) {
   sleep();
}
doSomethingWithConfig();
​
线程A
configOptions = processConfigOptions();

如何禁止执行重排序的?会插入一些内存屏障汇编机器指令,使重排序时无法将指令重排序到内存屏障之前或之后的位置。

 

volatile变量的注意点

volatile只能保证可见性,在以下场景中,需要使用锁(synchronized 或 juc包中的原子类)来保证原子性。

1)运算结果依赖变量的当前值

2)变量需要与其他的状态变量来共同构成不变性约束

运行20个线程,每个线程对race变量进行10000次自增操作。

如果正确并发的话,结果应该为200,000。

但是运行可以发现,每次输出结果都是一个小于200,000的值。

public class VolatileTest {
​
    public static volatile int race = 0;
​
    public static void increase() {
        race++;
    }
​
    private static final int THREAD_COUNT = 20;
​
    public static void main(String[] args) {
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < THREAD_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
​
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println(race);
    }
} 

volatile的正确使用场景

用于结束线程

volatile boolean stop;
​
public void shutdown() {
   stop = true;
}
​
public void doWork() {
   while (!stop) {
     // do stuff
   }
}

 

2)对于long和double型变量的特殊规则

Java内存模型允许没有被volatile修饰的64位数据(long和double)划分为两次32位操作。

目前,商用虚拟机都选择把64位数据的读写作为原子操作看待,因此,写代码时,不需要专门将long和double声明为volatile。

  

原子性、可见性、有序性

Java内存模型是围绕并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。

哪些操作实现了这3个特征那?

 

原子性:

1)对于基本数据类型的访问读写是具备原子性的。

2)synchronized块之间操作是原子性的(通过monitorenter和monitorexit两条指令)。

 

可见性:

可见性指当一个线程修改了共享变量的值,其他线程能够立即得知到。

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。

1)volatile保证可见性:保证新值能够立即同步到主内存,每次使用前从主内存刷新。

2)synchronized同步块保证可见性:对一个变量unlock之前,必须先把此变量同步回主内存

3)final保证可见性:被final修饰的字段在构造器中一但初始化完成,并且构造器没有把”this“引用传递出去,那其他线程就能够看到final字段的值。

 

有序性:

如果在本线程内观察,所有的操作都是有序的

(线程内表现为串行的语义:方法执行过程中所有依赖赋值结果的地方都能获取到正确的结果,但不保证变量赋值操作的顺序与程序代码中的顺序一致)

如果在一个线程观察另一个线程,所有的操作都是无序的

("指令重排序"现象 和 ”工作内存与主内存同步延迟“现象)。

1)volatile关键字通过禁止指令重排序的语义保证线程之间操作的有序性

2)synchronized通过”一个变量在同一个时刻只运行一个线程对其进行lock操作“这条规则,保证持有同一个锁的两个同步块只能串行进入

 

先行发生原则(happens-before)

先行发生原则指的是什么?

如果操作A先行发生于B,那么在发生操作B之前,操作A产生的影响能够被B观察到,影响包括修改了内存中共享变量的值、调用了方法等。

 

举例

 //线程A中执行
 i = 1;
 //线程B中执行
 j = i;
 

假设线程A中的操作i=1先行发生于线程B中的j=i。

可以断定线程B操作执行后,变量j一定等于1。

 //线程A中执行
 i = 1;
 //线程B中执行
 j = i;
 //线程C中执行
 i = 2;

 

加入线程C后,线程C操作在线程A和B操作之间,但线程C与B没有先行发生关系。

变量j的值将不确定,可能为1也可能为2。因为线程C对变量i的影响可能被线程观察到,也可能不会。这导致了线程安全问题。

 

先行发生原则可以用来做什么那?

可以用于判断数据是否存在竞争、线程是否安全。

 

Java内存模型下有以下天然的先行发生关系,无须任何同步协助就已经存在。

如果两个操作之间的关系不在此列,且无法根据下列规则推到出来,它们就没有顺序性保障,虚拟机可对他们随意重排序。

  • 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。

  • 管程锁定规则:对于同一个锁的unlock操作先行发生于后面的lock操作。”后面“指时间上的先后顺序。

  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。”后面“指时间上的先后顺序

  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。

  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测。可通过Thread.join()方法结束、Thread.isAlive()的返回值等手段判断线程已经终止运行。

  • 线程中断规则:对线程的interrupt()方法的调用先行发生于检测到中断事件的发生。可以通过Thread.interrupted()方法检测是否有中断发生

  • 对象终结规则:一个对象的处理化完成(构造函数执行结束)先行发生于它的finalize()方法的开始

  • 传递性:操作A先行发生于B,操作B先行发生于C,则操作A先行发生于操作C。

 

用先行发生原则判断是否线程安全的一个例子。

private int value = 0;
​
//线程A先执行setValue(10)  (时间上的先后)
public void setValue(int value) {
   this.value = value;
}
​
//线程B后执行getValue()
public int getValue() {
   return value;
}

线程B收到的返回值是什么?

 

上述所有先行发生原则都不适用。

因此,尽管线程A在操作上先于线程B,但无法确定线程B中getValue()的返回结果。

(一个操作”时间上先行发生“不代表这个操作是”先行发生”)

如何修复?

方法1:get/set方法用synchronized修饰 管程锁定规则

方法2:声明为volatile类型 volatile变量规则

 

(一个操作“先行发生”也不代表这个操作“时间上先行发生”)

//在同一个线程中执行
int i = 1;
int j = 2;

两个语句在同一个线程中执行,依据程序次序规则,int i = 1先行发生于int j = 2,但是由于执行重排序,int j = 2可能先被处理器执行。

 

总结:时间先后顺序与先行发生顺序之间没必然关系。判定线程并发安全问题时应不受时间顺序的干扰,以先行发生原则为准。

 

12.4 Java与线程

12.4.1 线程的实现

实现线程的3种方式

1.使用内核线程实现

内核线程(Kernel-Level Thread,KLT):直接由操作系统内核支持,由内核进行线程调度,并映射到各个处理器上。

程序中一般不直接使用内核线程,而是使用内核线程的高级接口——轻量级进程(Light Weight Process, LWP),即我们通常讲的线程。

轻量级进程与内核线程之间为1:1的关系称为【1对1的线程模型】。

 

局限性

1)基于内核线程实现,各种线程操作(如创建、销毁)都需要进行系统调用,在用户态和内核态之间切换,代价较高。

2)每个轻量级线程需要一个内核线程支持,需要消耗一定的内核资源(如内核线程的栈空间),因此系统支持的轻量级进程的数量有限

 

2. 使用用户线程实现

用户线程:完全建立在用户空间的线程库上,系统内核感知不到线程的存在,线程的创建、销毁、同步等完全在用户态完成。

进程与用户线程之间1:N的关系称为【1对多的线程模型】。

 

优势

1)线程创建、销毁等操作不需要切换到内核态,快且低消耗

2)支持更多的线程数量

劣势

所有的线程操作(创建、销毁)等都需要用户程序自己处理(用户程序依赖线程库,因此主要是线程库的实现需要处理这些问题)

而诸如”阻塞如何处理“”多处理器中如何将线程映射到其他处理器上“等问题解决起来较困难,甚至不可能完成。

目前,使用用户线程的程序越来越少了。

 

3.使用用户线程加轻量级进程混合实现

用户线程完全建立在用户空间中,因此线程的创建、销毁等操作依赖廉价,并可支持较大规模的线程数量。

轻量级进程作为用户线程和内核线程之间的桥梁,从而可以使用内核使用的线程调度和处理器映射功能。

用户线程和轻量级进程之间的数据量比是不定的,即N:M的关系,称为【多对多的线程模型】。

许多UNIX系列操作系统,都提供N:M线程模型实现。

 

12.4.2Java线程的实现

线程模型基于操作系统原生的线程模型实现,操作系统支持怎样的线程模型,很大程度上决定了Java虚拟机的线程模型。

这点在不同平台上没办法达成一致,虚拟机规范也未线程使用哪种线程模型来实现。

在Windows 、 Linux上都是一对一的线程模型,一条线程就映射到一条轻量级线程。

在Solaris平台,操作系统支持一对一及多对多线程模型,因此可通过指定虚拟机参数来指定要用哪种线程模型。

 

12.4.2Java线程调度

线程调度指系统为线程分配处理器使用权的过程。

线程调度方式有两种,分别是协同式调度、抢占式调度。

调度方式

说明

特点

协同式调度

线程的执行时间有线程本身控制,工作执行完后,主动通知系统切换到另一个线程上

好处:实现简单

坏处:线程执行时间不可控制。如果代码有问题,一直不告知系统进行线程切换,会导致进程阻塞

抢占式调度

线程的执行时间由系统来分配,线程切换由系统控制

好处:不会出现一个线程导致整个进程阻塞的情况

Java线程调度方式为抢占式调度。

关于线程优先级

虽然可通过将线程优先级调高使cpu分配更多的时间片,但是线程优先级不太靠谱。

Java线程映射到系统原生的线程上,线程调度最终那还是取决于操作系统。

 

12.4.3 Java线程状态转换

  • 新建(New):创建后但尚未启动的线程处于这种状态。

  • 运行(Runnable):包含了操作系统线程状态中的Running和Ready。线程有可能正在执行,也可能正在等待cpu为它分配时间片。

  • 无限期等待(Waiting):不会被cpu分配时间片,等待被其他线程唤醒。以下方法会让线程进行无限期等待状态

1)没有设置Timeout参数的Object.wait()方法

2)没有设置Timeout参数的Thread.join()方法

3)LockSupport.park()方法

  • 限期等待(Timed Waiting):不会被cpu分配时间片,在一段时间后由系统自动唤醒。

    1)Thread.sleep()

    2)设置了Timeout参数的Object.wait()方法

3)设置了Timeout参数额Thread.join()方法

4)LockSupport.parkNanos()

5) LockSupport.pardUntil()

  • 阻塞(Blocked):线程被阻塞了,在线程等待进行临界区时,线程进入阻塞状态

    阻塞状态与等待状态的区别

    阻塞状态:等待获取一个排它锁

    等待状态:等待一段时间 或者是 等待被唤醒

  • 结束(Terminated):已终止的线程的状态。

     

     

 

posted @ 2020-03-27 13:02  Ye_yang  阅读(770)  评论(1编辑  收藏  举报