zoukankan      html  css  js  c++  java
  • 《深入理解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):已终止的线程的状态。

  • 相关阅读:
    SQL性能优化思路
    EF Migraiton错误解决
    How to resolve the 403 error when send POST request from Postman
    Disable trigger to avoid the ID is auto-updated
    MBG(Mybatis Generator)配置
    Publish Web Site To IIS From VS
    quickSort算法导论版实现
    Clang与libc++abi库安装
    Clang与libc++abi库安装
    整数中1 的个数
  • 原文地址:https://www.cnblogs.com/yeyang/p/12580682.html
Copyright © 2011-2022 走看看