购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

3.1 JVM核心概念及JVM对高并发的支持

JVM是安装在操作系统上的Java虚拟机,Java代码直接操作的对象就是JVM(而不是操作系统)。不论是哪种操作系统,只要安装了JVM就能屏蔽各自操作系统的差异性,从而以一种相同规范的“虚拟机”形式和Java字节码交互。因此Java就可以实现“一次编写,到处运行”的平台无关性。JVM所处的位置如图3—1所示。

3.1.1 内存区域与内存模型

图3—1 JVM位置

本小节将对JVM的内存区域与内存模型进行讲解,这些JVM的基础知识也是本书后续讲解高并发技术的理论基础。

1. JVM内存区域

JVM在运行时,会将其管理的内存区域划分为方法区、堆、虚拟机栈、本地方法栈和程序计数器等5个区域;前2个区域是所有线程都可以共享的区域,而后3个区域是各个线程私有的,如图3—2所示。

图3—2 JVM运行时的内存区域

2. JVM内存模型(Java Memory ModeI,简称JMM)

JMM用于定义程序中变量的访问规则,即在JVM中如何将变量存储到内存,以及如何从内存中获取变量(此处的变量是指能被所有线程共享的变量,不含线程私有的局部变量和方法参数)。

与JVM内存区域不同,JMM是从另一个角度对内存进行划分,分为主内存和工作内存。

JMM规定所有的变量都存储在主内存中,每个线程还拥有自己独立的工作内存。主内存中的变量会通过复制的方式留给线程的工作内存一个副本,供各个线程独立使用。也就是说,线程对变量的所有读写操作都是在工作内存中进行的,工作内存中的副本变量会通过JMM与主内存中的原变量保持同步,如图3—3所示。

图3—3 线程的工作内存与主内存

除此以外,各个线程在运行期间必须遵循以下规定。

(1)只能访问自己工作内存中的变量。

(2)无法直接访问其他线程工作内存中的变量。

(3)可以通过主内存,间接访问其他线程工作内存中的变量。例如,假设线程B要访问线程A中的变量 a ,经历的大致步骤如下。

① 等待线程A将工作内存中变量 a 的副本更新到主内存中(Save)。

② 从主内存中,将更新后的变量 a 复制到线程B的工作内存中(Load),如图3—4所示。

图3—4 线程间访问变量的流程

实际上,除了上述步骤外,不同线程之间在进行数据交互时要完整地经历如图3—5所示的8个步骤:

①Lock:把主内存中的变量标识为一条线程独占状态;

②Read:把主内存中的变量读取到工作内存中;

③Load:将变量放入变量副本中;

④Use:把变量副本传递给线程使用;

⑤Assign:把线程正在使用的变量传递到工作内存中的变量副本中;

⑥Store:把工作内存中的变量副本传递到主内存中;

⑦Write:将变量副本作为一个变量放入主内存中;

⑧UnLock:解除线程的独占状态。

图3—5 线程间共享变量的完整步骤

JVM还要求以上8个步骤的操作都是原子性的,但是对于64位的数据类型却有着非原子性协议,JVM允许64位的long和double类型在执行Load、Store、Read和Write操作时,分成两次32位的原子性操作。这就意味着,多个线程共享一个long或double类型时,某一个线程理论上可能读到半个long或double值。如果真的遇到这种错误情况,读者可以使用volatile关键字来避免JVM的这种误操作。但从实际情况来看,目前主流的JVM都已经允许将64位的数据类型直接设置为原子性操作,一般情况下读者是不需要手动添加volatile的。

3.1.2 使用voIatiIe解决可见性与重排序问题

volatile是JVM提供的一个轻量级的同步机制,除了能够“避免JVM对long/double的误操作”外,还有以下两个作用。

1. voIatiIe修饰的变量可以对所有线程立即可见

前面讲过,不同线程如果要访问同一个变量,就必须借助主内存进行传递;但是如果给变量加了volatile关键字,则该变量的值就可以被所有线程即时感知(即某一个线程对volatile变量进行的任何操作,都会在第一时间同步到其他线程中)。

2. voIatiIe可以禁止指令进行“重排序”优化

在理解“重排序”以前,有必要先了解一下“原子性”,因为重排序的排序对象必须是原子性的语句。但是在Java中,并不是所有语句都是原子性的。例如,如果已经存在变量num,那么对num的赋值语句num=10是一个原子性操作;但是如果不存在age,声明并赋值age的语句int age = 23就不是一个原子性操作,该语句会在最终执行时拆分成以下两条语句:

①int age;

②age=23。

重排序是指JVM为了提高执行效率,会对编写的代码进行一些额外的优化。例如,会对已经写完的代码指令,重新进行排序。重排序所实现的优化不会影响单线程程序执行结果,代码如下所示。

再次强调,重排序的原则是“不会影响单线程程序执行结果”。因此,本段代码的实际执行顺序可以是1、2、3、4,也可以是2、3、1、4,还可以是2、1、3、4等,因为这几种可能的最终执行结果都是相同的。

更加细致地研究上述代码发现,由于第4行不是原子性操作,因此第4行可以拆为以下两条语句。

了解完原子性和重排序之后,再来看一个双重检查方式的懒汉式单例模式。

范例3-1 单例模式

源码: demo/ch03/Singleton.java】

上述代码的第8行也不是一个原子性操作,JVM会在执行时将这条语句大致拆分为以下3步。

(1)分配内存地址、内存空间。

(2)使用构造方法实例化对象。

(3)将Instance赋值为第1步分配好的内存地址。

由于重排序的存在,第8行的内部执行顺序可能是a、b、c,也可能是a、c、b;如果是后者,当某一个线程X正在执行第8行,具体是刚执行完c但还没执行b时(即Instance虽然已被赋了值、不再为null,但还没有实例化),另一个线程Y正好此时抢占了CPU并且执行到第5行,判断Instance不为null,因此线程Y会直接返回instance对象,但此Instance却是线程X还没有实例化的对象,所以后续在使用Instance时就会出错。

为了避免这种因为JVM重排序而造成的问题,我们就可以给Instance加上volatile关键字,如下所示。

这样一来,就算真正意义上实现了单例模式。

实际上,volatile是通过“内存屏障”来防止指令重排序的,具体的实现步骤如下:

①在volatile写操作前,插入一个StoreStore屏障;

②在volatile写操作后,插入一个StoreLoad屏障;

③在volatile读操作前,插入一个LoadLoad屏障;

④在volatile读操作后,插入一个LoadStore屏障。

此外,要特别注意的一点是,虽然volatile修饰的变量具有可见性,但是并不具备原子性,因此volatile不是线程安全的。要理解这点,就得明确区分“原子性”和“重排序”的概念:原子性是指某一条语句不可再拆分,而重排序是指某一条语句内部的多个指令的执行顺序。下面通过一个示例来说明volatile非线程安全。

范例3-2 volatile 非线程安全

源码: demo/ch03/TestVolatile_1.java】

当初始num=0时,创建了100个线程,并且每个线程都会执行20000次num++。因此如果volatile是线程安全的,那么最终应该打印2000000,但实际结果并非如此,运行结果如图3—6所示。

图3—6 非线程安全的运行结果

从运行结果可以发现,volatile并不能将所修饰的num设置为原子性操作(如num ++就不是原子性操作),这会造成num++被多个线程同时执行,最终导致出现漏加的线程不安全的情况(即最终的结果值远小于2000000)。

如果要将本程序改进为线程安全,可以使用java.util.concurrent.atomic包中提供的原子类型,代码如下。

范例3-3 atomic 原子性

源码: demo/ch03/TestVolatile_2.java】

运行结果如图3—7所示。

图3—7 原子性操作的运行结果

除了本例使用的AtomicInteger外,在java.util.concurrent.atomic包中还提供了形如“Atomic×××”的其他常见的原子性变量对象。

观察AtomicInteger的源代码,可以看到一个compareAndSet()方法,其源码如下所示。

源码: java.util.concurrent.atomic.AtomicInteger】

此方法是实现原子性操作的关键,它实现了CAS算法,而CAS算法能够保证变量的原子性操作。 EpkpS/RqS6+zpSKnKDcRIzyJkIwaKSEWNdSsEA6Lm6gjW67PiwQN/1U9fqmwW1br

点击中间区域
呼出菜单
上一章
目录
下一章
×