JVM系列实践:Java内存模型与线程

作者:陆金龙    发表时间:2022-12-24 01:56   

关键词:volatile  synchronized  monitorneter  monitorexit  

1.并发处理

并发处理的应用使得Amdahl定律取代摩尔定律成为计算机性能发展的源动力。

Amdahl定律:S=1/((1-a)+a/n)。a是并行计算部分占比,n是并行处理节点数。

(1)增加处理器数,计算负载分布到更多处理器上,提高计算速度。

(2)程序中可并行代码比例决定增加处理器带来速度提升的上限。

2.内存模型

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量的底层细节。这里的变量包括静态字段、实例字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者是存放在各线程对应的虚拟机栈上,是线程私有的。

2.1 主内存和工作内存

Java内存模型规定了所有变量都存储在主内存,每条线程的工作内存保存该线程使用到的变量的主内存副本拷贝。线程间变量值的传递均需要通过主内存完成。

主内存主要对应Java堆中对象实例数据部分,在运行时数据区中对应方法区和堆。而工作内存则对应虚拟机栈中的部分区域,以及程序计数器和本地方法栈。

Java虚拟机的运行时数据区组成如下:

2.2 内存间交互操作

lock(锁定):主内存,把一个变量标记为一个线程独占。同一时刻只允许一个线程对其进行lock操作。对应字节码指令是monitorneter。

unlock(解锁):主内存,把一个处于锁定状态的变量释放出来。一个变量执行unlock前,必须先把此变量同步回主内存。字节码指令是monitornexit。

        多个线程执行过程中,可能使用lock、unlock,也可能不使用。由用户编写程序时根据需要决定。

read(读取):主内存,把一个变量的值从主内存传输到线程的工作内存中。

load(载入):工作内存,把read操作传输到工作内存的变量值放入工作内存的变量副本中。与read成对出现。

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

assign(赋值):工作内存,把执行引擎接收到的值赋给工作内存的变量。(虚拟机遇到给变量赋值的字节码时发生)。

                            不允许一个线程丢弃它的assign操作,工作内存的改变,必须同步回主内存(通过store和write)。

store(存储):工作内存,把工作内存中的一个变量的值传送到主内存中。

write(写入):主内存,把store操作传送到主内存的变量值写入主内存的变量中。wrtie与store成对出现。

一个新的变量只能从主内存中诞生,不允许在工作内存中使用一个未被初始化(load或assign)的变量。

2.3 volatile型变量的特殊规则

一个定义为volatile的变量特性:一是保证此变量对所有线程的可见性(通过每次使用前刷新实现)。但并不能保证volatile变量在并发下是安全的。二是禁止指令重排序优化。

因此仍然需要通过加锁来保证原子性。但符合以下两条规则的场景可以不加锁:

运算结果并不依赖变量的当前值,或者确保只有一个线程修改变量的值。

变量不需要与其他的状态变量共同参与不变约束。

特殊规则:(1)只有当线程T对变量V执行的前一个动作是load的时候,才能对变量V执行use动作。即read->load->use必须连续出现。(2)只有当线程T对变量V执行的前一个动作是lassign的时候,才能对变量V执行store动作。即assign->store->write必须连续出现。

2.4 原子性、可见性、有序性

原子性:有Java内存模型直接保证的原子性变量操作包括read、load、assign、use、store、write,可以大致认为基本类型的访问读写是原子性的。更大范围的原子性保证通过lock和和unlock操作满足,字节码指令是monitorneter、monitorexit,反应到Java代码中就是同步块synchronized关键字。

可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式实现可见性。volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即刷新。除了volatile之外,Java还有synchronized和final两个关键字能实现可见性。

有序性:Java语言提供了volatile和synchronized两个关键字保证线程之间操作的有序性。
volatile关键字本身就包含了禁止指令重排序,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得。synchronized伴随着较大的性能影响。

3 先行发生原则

先行发生:先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被B观察到,“影响”包括修改了内存中共享变量的值,发送了消息、调用了方法等。

如果两个操作不满足先行发生关系,它们就没有顺序保障,虚拟机可以对它们随意进行重排。

Java内存模型下一些“天然的”先行发生关系:

程序次序规则(Program Order Rule):一个线程内的控制流顺序。

管程锁定规则(Monitor Lock Rule):unlock先行发生于下一个lock。

volatile变量规则(Volatile Variable Rule):一个volatile变量的写操作先行发生于后面对这个变量的读操作。

线程启动规则(Thread Start Rule):start()先于此线程内的每个动作。

线程终止规则(Thread Termination Rule)此线程内的所有动作都先行于对此线程的终止检测。。

线程中断规则(Thread Interruption Rule)线程interrupt方法调用先行发生于被中断线程的代码检测到中断事件的发生。

对象终结规则(Finalizer Rule)对象初始化先行发生于它的finalize()方法。

传递性(Transitivity)。

4.Java与线程

4.1 线程的实现

使用内核线程实现和使用用户线程实现。

使用内核线程实现:使用内核线程的一种高级接口——轻量级进程(LWP)。由于基于内核线程实现,系统调用的代价相对较高,需要在用户态和内核态中来回切换。一个系统支持轻量级进程的数量是有限的。

使用用户线程实现:完全建立在用户空间的线程库上,用户线程(UT)的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。用户线程实现的程序一般都比较复杂。

使用用户线程加轻量级进程混合实现:用户线程还是完全建立在用户空间中,用户线程的创建、切换、析构等操作依然廉价,可支持大规模的用户线程并发。轻量级进程作为用户线程与内核线程的桥梁,用户线程的系统调用则提供过轻量级进程来完成。

Java线程的实现:JDK1.2之前,是基于称为绿色线程的用户线程实现。JDK1.2,线程模型替换为操作系统原生线程模型实现。Sun JDK的Windows与Linux版都是使用一对一的线程模型实现,一条Java线程就映射到一条轻量级进程之中。

4.2 Java线程调度

线程调度有协同式线程调度和抢占式线程调度。

协同式线程调度:线程的执行时间由线程本身控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。Lua语言的“协同例程”就是这类实现。

抢占式线程调度:每个线程将有系统来分配执行时间,线程的切换不由线程本身来决定。Java线程调度是系统自动完成的,但是可以给线程设置优先级,优先级越高的线程越容易被系统选择执行。

4.3 线程状态

Java语言定义了以下几种线程状态:

新建(New):创建后尚未启动。

运行(Running):运行中

无限期等待(Waiting):等待被其他线程显示唤醒。

限期等待(Timed Waiting):在一定时间后由系统自动唤醒。

阻塞(Blocked):线程被阻塞了,等待获取到一个排他锁。

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

线程状态转换关系如下图所示:

5.Java语言线程安全

5.1 Java语言线程安全程度

Java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立。

不可变:如果共享数据是基本数据类型,声明为final。还有String、枚举、Long、Double、BigInteger、BigDecimal。

绝对线程安全:不管运行环境如何,调用者都不需要任何额外的同步措施。Java API中大多数类都不是绝对线程安全的。

相对线程安全:需要保证这个对象单独的线程操作是安全的,对于一些特定顺序的连续调用,需要在调用端使用额外的同步手段来保证调用的正确性。Vector、HashTable、Collections的synchronized Collection()方法包装的集合等。

线程兼容:对象本身不是线程安全的,但可以通过调用端正确地使用同步手段保证对象在并发环境中安全使用。Java API中大多数类都是线程兼容的。

线程对立:无论调用端是否采取同步措施,都无法在多线程环境中并发使用。应尽量避免。Thread的suspend()和resume()。

5.2 线程安全的实现方法

(1)互斥同步:互斥锁、同步块。如synchronized关键字。java.util.concurrent包中的ReentantLock。

                           带来线程阻塞和线程唤醒的性能问题。

(2)非阻塞同步:基于冲突检查的乐观并发策略,先进行操作,如果没有其他线程争用共享数据,就成功了;如果产生冲突,就再采取补救措施。需要硬件指令集支持。

(3)无同步方案:可重入代码(只要输入相同的数据,就返回相同的结果)和线程本地存储(Web交互中一个请求对应一个服务器线程)。

5.3 锁优化

自旋锁:执行一个忙循环(自旋)。自旋等待时间必须要有一定的限度,超过限定次数,就使用传统的方式挂起线程。

自适应自旋:自适应由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定。自旋刚刚获得过锁,虚拟机将允许自旋等待的时间相对更长。

锁消除:对不可能存在共享数据竞争的锁进行消除。

锁粗化:探测到一串零碎的操作都对同一个对象加锁,将会把加锁同步范围扩展到整个操作序列的外部,只需要加锁一次就可以了。

锁分级:轻量级锁->重量级锁。首先使用轻量级锁。当有两个以上线程争用同一个锁,则轻量级锁不再有效。

偏向锁:持有偏向锁的线程以后每次进入这个锁的同步块时,虚拟机都可以不用再进行任何同步操作。