深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) 周志明 笔记记录
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(主内存物理上只是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory),线程的工作内存保存了该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类实现细节,Java内存模型中定义了以下8种操作来完成。Java虚拟机实现时必须保证下面的每一种操作都是源自的不可再分的
把一个变量从主内存拷贝到工作内存,那就要按顺序执行read(读取)和load(载入)操作。把变量从工作内存同步回主内存,那就要按顺序执行store(存储)和write(写入)操作。
Java内存模型要求read和load操作、store和write操作必须按顺序执行,但不要求是连续执行。也就是read和load之间、store和write之间可以插入其他指令,比如对主内存a、b进行访问
可以出现read a、read b、load b、load a。
Java内存模型还规定了在执行上述8种基本操作时必须满足以下规则:
Java内存模型的操作简化为read、write、lock和unlock四种,但这只是语言描述上的等价化简,Java内存模型的基础设计并未改变。
当一个变量被定义成volatile之后,它将具备两项特性:
内存可见性
一个变量被volatile修饰之后,保证此变量对所有线程的可见性,即当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时需要通过主内存来完成。
内存可见性指的是当一个线程对volatile修饰的变量进行写操作时,JMM会立即把该线程对应的本地内存中的共享变量的值刷新到主内存;当一个线程对volatile修饰的变量进行读操作时,JMM会把立即该线程对应的本地内存置为无效,从主内存中读取共享变量的值。
/**
* 去掉volatile之后,主线程一直会死循环,因为i在main线程本地内存中,并不知道i已经被其它线程修改
* 加上volatile
*/
private volatile static int i = 0;
/**
* 验证内存可见性。
* 当加了volatile修饰的共享变量,被线程修改时,会立刻将修改的值刷新回主内存,并将其它线程的内存地址置位无效,其它线程在使用
* volatile修饰的共享变量时发现无效,会回主内存重新获取 (MESI缓存一致性协议)
* @param args
* @throws Exception
*/
public static void main(String[] args) throws Exception{
//第一个线程把i改成1
new Thread(() -> {
try {
Thread.sleep(1000);
i = 1;
System.out.println("i被线程" + Thread.currentThread().getName() + "改成:" + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
},"线程NAME").start();
//main线程当i!=0时结束
while (i == 0){
//注意while循环不要做线程sleep或者获取锁(synchronized)操作,会使当前线程去主内存重新同步共享变量的值
// System.out.println 内部也是加锁(synchronized)操作
}
System.out.println("主线程结束====================");
}
volatile不保证原子性
volatile会保证内存可见性,即每次线程读取到的值都是最新的值(被其他线程修改),但Java的运算符操作并非原子的,比如i++操作(i为volatile修饰的共享变量,多个线程进行++操作)。因为i++操作,并非一条字节码指令实现。volatile只保证读值的字节码命令读的是正确的,不能保证之后的字节码执行时没有其他线程也去进行操作这个变量。
定义一个i++代码
public class Add {
private static int i = 0;
public static void main(String[] args) {
i ++;
}
}
使用javap命令反编译
#将命令的输出 输出到文件D:\\add\code.txt上
javap -c D:\add\Add.class >> D:\\add\code.txt
反编译文件,可以看到i++是由四个指令(getstatic、iconst_1、iadd、putstatic)完成,volatile保证getstatic指令取到的值是没问题的,不保证接下来的指令。
即使编译出来只有一条字节码指令,也并不意味执行这条指令是一个原子操作,一条字节码指令在解释执行时,解释器要运行许多行代码才能实现它的语义。如果是编译执行,一条字节码指令也可能转化成若干条本地机器码指令。
Compiled from "Add.java"
public class jvm.Add {
public jvm.Add();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field i:I
3: iconst_1
4: iadd
5: putstatic #2 // Field i:I
8: return
static {
};
Code:
0: iconst_0
1: putstatic #2 // Field i:I
4: return
}
多线程验证volatile不保证原子性
private volatile static int count = 0;
/**
* volatile不保证原子性
* @param args
*/
public static void main(String[] args) {
for(int i = 0;i < 10 ;i ++){
new Thread(() ->{
for(int j = 0; j < 10000; j ++){
count ++;
}
}).start();
}
while (Thread.activeCount() > 1){
Thread.yield();
}
//如果保证原子性 count值应该为 10000 * 10
System.out.println("count的值为:" + count);
}
禁止指令重排序
普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
JVM通过内存屏障限制处理器重排序。
主流的操作系统都提供了线程的实现。Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表一个线程。
实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N),使用用户线程加轻量级进程混合实现(N:M实现)。
内核线程实现
使用内核线程实现的方式也被称为1:1实现。内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操作调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核。
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才有轻量级进程。
轻量级进程与内核线程之间1:1的关系称为一对一线程模型,如图,(P进程)
由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程
在系统调用中被阻塞了,也不会影响整个进程继续工作。轻量级进程也具有它的局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
用户线程实现
使用用户线程(User Thread,UT)实现的方式被称为1:N实现。广义上讲,一个线程只要不是内核线程,都可以认为是用户线程的一种,从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都有进行系统调用,因此效率会受到限制,并不是具备通常意义上的用户线程的优点。
而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存
在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型,如图:
用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。因为使用用户线程实现的程序通常都比较复杂,除了有明确的需求外,一般的应用程序都不倾向使用用户线程。Java、Ruby等语言都曾经使用过用户线程,最终又都放弃了使用它。但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang、Erlang等,使得用户线程的使用率有所回升。
混合实现
线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现。在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是N:M的关系,多对多的线程模型如图:
Java线程如何实现并不受Java虚拟机规范约束,与具体虚拟机实现相关。
HotSpot虚拟机,每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而中间没有额外的间接结构,所以HotSpot虚拟机自己不会去干涉线程调度(线程可以设置优先级给系统建议,并不是一定会按照这个优先级执行),全权交给操作系统去处理,何时冻结、唤醒或给线程分配多少处理器执行时间、把该线程安排给哪个处理核心去执行等,都是由操作系统完成,也是有操作系统全权决定的。
线程调度是指系统为线程分配处理器使用权的过程,调用主要方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。
协同式线程调度
协同式线程调度线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另一个线程上去。协同式线程调度最大的好处就是实现简单,由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程本身是可知道,没有线程同步问题。坏处也很明显:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。
抢占式线程调度
抢占式线程调度每个线程由系统来分配执行时间,线程的切换不由线程本身来决定。在Java中可以主动让出执行时间(调用Thread::yield()方法),但是如果想要主要获取执行时间,线程本身是没有办法的,Java中可以通过设置线程优先级,来给操作系统一些意见,优先级越高的线程越容易被系统选择执行(容易并不是一定)。Java使用的线程调度方式就是抢占式调度。
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自旋锁在JDK 1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK 6中就已经改为默认开启了。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改。
在JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
轻量级锁是JDK6时加入的新型锁机制,轻量级是相对于使用操作系统互斥量来实现的传统锁而言,因此传统锁机制被称为重量级锁。轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
轻量级锁的工作过程
在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word)。然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced
Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。
偏向锁也是JDK 6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。
一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。