https://docs.oracle.com/javase/8/
https://docs.oracle.com/javase/8/docs/
Run-Time Data Areas
Java 运行时数据区图:
私有
- 程序计数器:记录当前线程所执行字节码的行号指示器。
- 虚拟机栈(stack):存放了当前线程调用方法的局部变量表、操作数栈、动态链接、方法返回值等信息(可以理解为线程的栈)。
- 本地方法栈(native stack):为虚拟机使用的 native 方法提供服务,后多与 JVM stack 合并为一起。
共享
- Java堆(heap):占据了虚拟机管理内存中最大的一块,唯一目的就是存放对象实例(与引用是两个概念),也是垃圾回收器主要管理的地方,故又称 GC 堆。
- 方法区(method area):存储加载的类信息、常量区、静态变量、JIT(即时编译器)处理后的数据等,类的信息包含类的版本、字段、方法、接口等信息。需要注意是常量池就在方法区中。
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
栈
Java 的栈中主要存放一些基本类型的变量(int, short, long, byte, float, double, boolean, char)和对象句柄。
存取速度比堆要快,仅次于寄存器。但缺点是,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。
栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。栈有 3 个部分:基本类型变量区、执行环境上下文、操作指令区。
如果栈内存没有可用的空间存储方法调用和局部变量,JVM 会抛出 java.lang.StackOverFlowError。
堆
Java 的堆是用来存放由 new
关键字创建的对象和数组,它们不需要程序代码来显式的释放。堆是由垃圾回收来负责的,堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java 的垃圾收集器会自动收走这些不再使用的数据。但缺点是,由于要在运行时动态分配内存,存取速度较慢。
堆内存中的对象对所有线程可见,堆内存中的对象可以被所有线程访问。
如果堆内存没有可用的空间存储生成的对象,JVM 会抛出 java.lang.OutOfMemoryError。
方法区
方法区(Method Area)与在逻辑上是堆的一部分,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的
代码等数据(是唯一的数据)。当 java 虚拟机通过类加载器加载这个类的时候,这个类的信息就会保存到方法区中,虽然 Java 虚拟机规范把方法区描述为堆的
一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
方法区因为总是存放不会轻易改变的内容,故又被称之为“永久代”。
HotSpot也选择把GC分代收集扩展至方法区,但也容易遇到内存溢出问题。可以选择不实现垃圾回收,但如果回收就主要涉及常量池的回收和类的卸载
JDK1.6 之前字符串常量池位于方法区之中。
JDK1.7 字符串常量池已经被挪到堆之中。
常量池
全局字符串池(string pool也有叫做string literal pool)。
全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到 string pool 中(记住:string pool 中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。)。
在 HotSpot VM 里实现的 string pool 功能的是一个 StringTable 类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个 StringTable 引用之后就等同被赋予了”驻留字符串”的身份。这个 StringTable 在每个 HotSpot VM 的实例只有一份,被所有的类共享。
静态常量池,即class文件常量池(class constant pool)
class 文件常量池图:
Java 代码被编译后生成。class 文件中的常量池不仅仅包含字符串(数字)字面量,还包含类、方法的信息,占用 class 文件绝大部分空间。
运行时常量池
在虚拟机在完成类装载操作后,将 class 文件中的常量池载入到内存中,并保存在方法区中。我们常说的常量池,就是指方法区中的运行时常量池。
运行时常量池与Class文件常量池区别
- JVM 对 Class 文件中每一部分的格式都有严格的要求,每一个字节用于存储那种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行;但运行时常量池没有这些限制,除了保存 Class 文件中描述的符号引用,还会把翻译出来的直接引用也存储在运行时常量区。
- 相较于 Class 文件常量池,运行时常量池更具动态性,在运行期间也可以将新的变量放入常量池中,而不是一定要在编译时确定的常量才能放入。最主要的运用便是 String 类的 intern() 方法。
- 在方法区中,常量池有运行时常量池和 Class 文件常量池。
String.intern()
检查字符串常量池中是否存在 String 并返回池里的字符串引用;若池中不存在,则将其加入池中,并返回其引用。
这样做主要是为了避免在堆中不断地创建新的字符串对象
Java 内存模型和多线程
内存模型的相关概念
计算机在执行程序时,每条指令都是在 CPU 中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于 CPU 执行速度很快,而从内存读取数据和向内存写入数据的过程跟 CPU 执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在 CPU 里面就有了高速缓存。
也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存当中,那么 CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:
1 | i = i + 1; |
当线程执行这个语句时,会先从主存当中读取 i 的值,然后复制一份到高速缓存当中,然后 CPU 执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中 i 最新的值刷新到主存当中。
这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的 CPU 中,因此每个线程运行时有自己的高速缓存(对单核 CPU 来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核 CPU 为例。
比如同时有2个线程执行这段代码,假如初始时i的值为 0,那么我们希望两个线程执行完之后 i 的值变为 2。但是事实会是这样吗?
可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的 CPU 的高速缓存当中,然后线程 1 进行加 1 操作,然后把 i 的最新值 1 写入到内存。此时线程 2 的高速缓存当中 i 的值还是 0,进行加 1 操作之后,i 的值为 1,然后线程 2 把 i 的值写入内存。
最终结果 i 的值是 1,而不是 2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。
也就是说,如果一个变量在多个 CPU 中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。
为了解决缓存不一致性问题,通常来说有以下 2 种解决方法:
1)通过在总线加 LOCK# 锁的方式
2)通过缓存一致性协议
这2种方式都是硬件层面上提供的方式。
在早期的 CPU 当中,是通过在总线上加 LOCK# 锁的形式来解决缓存不一致的问题。因为 CPU 和其他部件进行通信都是通过总线来进行的,如果对总线加 LOCK# 锁的话,也就是说阻塞了其他 CPU 对其他部件访问(如内存),从而使得只能有一个 CPU 能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了 LOCK# 锁的信号,那么只有等待这段代码完全执行完毕之后,其他 CPU 才能从变量 i 所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。
但是上面的方式会有一个问题,由于在锁住总线期间,其他 CPU 无法访问内存,导致效率低下。
所以就出现了缓存一致性协议。最出名的就是 Intel 的 MESI 协议,MESI 协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态,因此当其他 CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
在 Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,JMM)来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。那么 Java 内存模型规定了哪些东西呢,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序。注意,为了获得较好的执行性能,Java 内存模型并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序。也就是说,在 java 内存模型中,也会存在缓存一致性问题和指令重排序的问题。
Java 内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
举个简单的例子,在 java 中,执行下面这个语句:
1 | i = 10; |
执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值 10 写入主存当中。
那么 Java 语言本身对原子性、可见性以及有序性提供了哪些保证呢?
1.原子性
在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子:
请分析以下哪些操作是原子性操作:
1 | x = 10; //语句1 |
咋一看,有些朋友可能会说上面的 4 个语句中的操作都是原子性操作。其实只有语句 1 是原子性操作,其他三个语句都不是原子性操作。
语句 1 是直接将数值 10 赋值给 x,也就是说线程执行这个语句的会直接将数值 10 写入到工作内存中。
语句 2 实际上包含 2 个操作,它先要去读取 x 的值,再将 x 的值写入工作内存,虽然读取 x 的值以及 将 x 的值写入工作内存 这 2 个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,x++ 和 x = x + 1 包括 3 个操作:读取 x 的值,进行加 1 操作,写入新的值。
所以上面 4 个语句只有语句 1 的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
不过这里有一点需要注意:在 32 位平台下,对 64 位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是在最新的 JDK 中,JVM 已经保证对 64 位数据的读取和赋值也是原子性操作了。
从上面可以看出,Java 内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
2.可见性
对于可见性,Java 提供了 volatile 关键字来保证可见性。
当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3.有序性
在 Java 内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在 Java 里面,volatile 关键字修饰的变量不会被指令重排序优化,所以它具有有序性。另外 synchronized 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外,Java 内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
下面就来具体介绍下 happens-before 原则(先行发生原则):
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作
- volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C
- 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每个一个动作
- 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于它的 finalize() 方法的开始
这 8 条原则摘自《深入理解 Java 虚拟机》。
这 8 条规则中,前 4 条规则是比较重要的,后 4 条规则都是显而易见的。
下面我们来解释一下前 4 条规则:
对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。
第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
第四条规则实际上就是体现 happens-before 原则具备传递性。
多线程与并发
volatile 关键字
synchronized 关键字
在 C 程序代码中我们可以利用操作系统提供的互斥锁来实现同步块的互斥访问及线程的阻塞及唤醒等工作。然而在 Java 中除了提供 Lock API 外还在语法层面上提供了 synchronized 关键字来实现互斥同步原语。那么到底在 JVM 内部是怎么实现 synchronized 关键字的呢?
synchronized 的字节码表示
synchronized 通过锁机制实现同步。Java 中的每一个对象都可以作为锁。具体表现为以下 3 种形式。
对于普通同步方法,锁是当前实例对象。
对于静态同步方法,锁是当前类的 Class 对象。
对于同步方法块,锁是 synchronized 括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。
对于 synchronized 语句当 Java 源代码被 javac 编译成 bytecode 的时候,会在同步块的入口位置和退出位置分别插入 monitorenter 和 monitorexit 字节码指令。而 synchronized 方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn 指令,在 VM 字节码层面并没有任何特别的指令来实现被 synchronized 修饰的方法,而是在 Class 文件的方法表中将该方法的 access_flags 字段中的 synchronized 标志位置 1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的 Class 在 JVM 的内部对象表示 Klass 做为锁对象。
synchronized 具体实现
同步代码块采用 monitorenter、monitorexit 指令显式的实现。
monitorenter
每一个对象都有一个 monitor,一个 monitor 只能被一个线程拥有。当一个线程执行到 monitorenter 指令时会尝试获取相应对象的 monitor,获取规则如下:
- 如果 monitor 的进入数为 0,则该线程可以进入 monitor,并将 monitor 进入数设置为 1,该线程即为 monitor 的拥有者。
- 如果当前线程已经拥有该 monitor,只是重新进入,则进入 monitor 的进入数加 1,所以 synchronized 关键字实现的锁是可重入的锁。
- 如果 monitor 已被其他线程拥有,则当前线程进入阻塞状态,直到 monitor 的进入数为 0,再重新尝试获取 monitor。
monitorexit
只有拥有相应对象的 monitor 的线程才能执行 monitorexit 指令。每执行一次该指令 monitor 进入数减 1,当进入数为 0 时当前线程释放 monitor,此时其他阻塞的线程将可以尝试获取该 monitor。
同步方法则使用 ACC_SYNCHRONIZED 标记符隐式的实现。
synchronized 方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn 指令,在 VM 字节码层面并没有任何特别的指令来实现被 synchronized 修饰的方法,而是在 Class 文件的方法表中将该方法的 access_flags 字段中的 synchronized 标志位置 1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的 Class 在 JVM 的内部对象表示 Klass 做为锁对象。(摘自:http://www.cnblogs.com/javaminer/p/3889023.html)
对象头(Object Header)
在 JVM 中创建对象时会在对象前面加上两个字大小的对象头,在 32 位机器上一个字为 32 bit,根据不同的状态位 Mark Word 中存放不同的内容,如上图所示在轻量级锁中,Mark Word 被分成两部分,刚开始时 LockWord 位被设置为 HashCode,最低三位表示 LockWord 所处的状态,初始状态为 001,表示无锁状态。Klass ptr 指向 Class 字节码在虚拟机内部的对象表示的地址。Fields 表示连续的对象实例字段。
Monitor Record
Monitor Record 是线程私有的数据结构,每一个线程都有一个可用 monitor record 列表,同时还有一个全局的可用列表;那么这些 monitor record 有什么用呢?每一个被锁住的对象都会和一个 monitor record 关联(对象头中的 LockWord 指向 monitor record 的起始地址,由于这个地址是 8 byte 对齐的所以 LockWord 的最低三位可以用来作为状态位),同时 monitor record 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。如下图所示为 Monitor Record 的内部结构:
Owner:初始时为 NULL,表示当前没有任何线程拥有该 monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为 NULL。
EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住 monitor record 失败的线程。
RcThis:表示 blocked 或 waiting 在该 monitor record 上的所有线程的个数。
Nest:用来实现重入锁的计数。
HashCode:保存从对象头拷贝过来的 HashCode 值(可能还包含 GC age)。
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate 只有两种可能的值,0 表示没有需要唤醒的线程,1 表示要唤醒一个继任线程来竞争锁。
JVM 中锁的优化
简单来说在 JVM 中 monitorenter 和 monitorexit 字节码依赖于底层的操作系统的 Mutex Lock 来实现的,但是由于使用 Mutex Lock 需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境),如果每次都调用 Mutex Lock 那么将严重的影响程序的性能。不过在 jdk1.6 中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。
锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的 unlock,lock 操作,将多个连续的锁扩展成一个范围更大的锁。
锁消除(Lock Elimination):通过运行时 JIT 编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地 Stack 上进行对象空间的分配(同时还可以减少 Heap 上的垃圾收集开销)。
偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的 CAS 原子指令,因为 CAS 原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟(可参考这篇文章)。
轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在 monitorenter 和 monitorexit 中只需要依靠一条 CAS 原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行 CAS 指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。
适应性自旋(Adaptive Spinning):当线程在获取轻量级锁的过程中执行 CAS 操作失败时,在进入与 monitor 相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该 monitor 关联的 semaphore(即互斥锁)进入到阻塞状态。
偏向锁的思想是指如果一个线程获得了锁,那么就从无锁模式进入偏向模式,这一步是通过 CAS 操作来做的,进入偏向模式的线程每一次访问这个锁的同步代码块时都不需要再进行同步操作,除非有其他线程访问这个锁。
偏向锁提高的是那些带同步但无竞争的代码的性能,也就是说如果你的同步代码块很长时间都是同一个线程访问,偏向锁就会提高效率,因为他减少了重复获取锁和释放锁产生的性能消耗。如果你的同步代码块会频繁的在多个线程之间访问,可以使用参数 -XX:-UseBiasedLocking
来禁止偏向锁产生,避免在多个锁状态之间切换。
偏向锁优化了只有一个线程进入同步代码块的情况,当多个线程访问锁时偏向锁就升级为了轻量级锁。
轻量级锁的思想是当多个线程进入同步代码块后,多个线程未发生竞争时一直保持轻量级锁,通过 CAS 来获取锁。如果发生竞争,首先会采用 CAS 自旋操作来获取锁,自旋在极短时间内发生,有固定的自旋次数,一旦自旋获取失败,则升级为重量级锁。
轻量级锁优化了多个线程进入同步代码块的情况,多个线程未发生竞争时,可以通过 CAS 获取锁,减少锁状态切换。当多个线程发生竞争时,不是直接阻塞线程,而是通过 CAS 自旋来尝试获取锁,减少了阻塞线程的概率,这样就提高了 synchronized 锁的性能。
轻量级锁具体实现
一个线程能够通过两种方式锁住一个对象:
- 通过膨胀一个处于无锁状态(状态位 001)的对象获得该对象的锁;
- 对象已经处于膨胀状态(状态位 00)但 LockWord 指向的 monitor record 的 Owner 字段为 NULL,则可以直接通过 CAS 原子指令尝试将 Owner 设置为自己的标识来获得锁。
获取锁(monitorenter)的大概过程如下:
(1)当对象处于无锁状态时(RecordWord 值为 HashCode,状态位为 001),线程首先从自己的可用 monitor record 列表中取得一个空闲的 monitor record,初始 Nest 和 Owner 值分别被预先设置为 1 和该线程自己的标识,一旦 monitor record 准备好,然后我们通过 CAS 原子指令安装该 monitor record 的起始地址到对象头的 LockWord 字段来膨胀(原文为inflate,我觉得之所以叫 inflate 主要是由于当对象被膨胀后扩展了对象的大小;为了空间效率,将 monitor record 结构从对象头中抽出去,当需要的时候才将该结构 attach 到对象上,但是和这篇 Paper 有点互相矛盾,两种实现方式稍微有点不同)该对象,如果存在其他线程竞争锁的情况而调用 CAS 失败,则只需要简单的回到 monitorenter 重新开始获取锁的过程即可。
(2)对象已经被膨胀同时 Owner 中保存的线程标识为获取锁的线程自己,这就是重入(reentrant)锁的情况,只需要简单的将 Nest 加 1 即可。不需要任何原子操作,效率非常高。
(3)对象已膨胀但 Owner 的值为 NULL,当一个锁上存在阻塞或等待的线程同时锁的前一个拥有者刚释放锁时会出现这种状态,此时多个线程通过 CAS 原子指令在多线程竞争状态下试图将 Owner 设置为自己的标识来获得锁,竞争失败的线程在则会进入到第四种情况(4)的执行路径。
(4)对象处于膨胀状态同时 Owner 不为 NULL(被锁住),在调用操作系统的重量级的互斥锁之前先自旋一定的次数,当达到一定的次数时如果仍然没有成功获得锁,则开始准备进入阻塞状态,首先将 rfThis 的值原子性的加 1,由于在加 1 的过程中可能会被其他线程破坏 Object 和 monitor record 之间的关联,所以在原子性加 1 后需要再进行一次比较以确保 LockWord 的值没有被改变,当发现被改变后则要重新进行 monitorenter 过程。同时再一次观察 Owner 是否为 NULL,如果是则调用 CAS 参与竞争锁,锁竞争失败则进入到阻塞状态。
释放锁(monitorexit)的大概过程如下:
(1)首先检查该对象是否处于膨胀状态并且该线程是这个锁的拥有者,如果发现不对则抛出异常;
(2)检查 Nest 字段是否大于 1,如果大于 1 则简单的将 Nest 减 1 并继续拥有锁,如果等于 1,则进入到第(3)步;
(3)检查 rfThis 是否大于 0,设置 Owner 为 NULL 然后唤醒一个正在阻塞或等待的线程再一次试图获取锁,如果等于 0 则进入到第(4)步
(4)缩小(deflate)一个对象,通过将对象的 LockWord 置换回原来的 HashCode 值来解除和 monitor record 之间的关联来释放锁,同时将 monitor record 放回到线程是有的可用 monitor record 列表。
CAS
什么是 CAS
在 jdk 1.5 中增加的一个最主要的支持是 Atomic 类,比如说 AtomicInteger、AtomicLong,这些类可帮助最大限度地减少在多线程中对于一些基本操作(例如,增加或减少多个线程之间共享的值)的复杂性。而这些类的实现都依赖于 CAS(compare and swap / compare and set)算法。
乐观锁和悲观锁
cpu 是时分复用的,也就是把 cpu 的时间片,分配给不同的 thread/process 轮流执行,时间片与时间片之间,需要进行 cpu 切换,也就是会发生进程的切换。切换涉及到清空寄存器,缓存数据。然后重新加载新的 thread 所需数据。当一个线程被挂起时,加入到阻塞队列,在一定的时间或条件下,在通过 notify(),notifyAll() 唤醒回来。在某个资源不可用的时候,就将cpu让出,把当前等待线程切换为阻塞状态。等到资源(比如一个共享数据)可用了,那么就将线程唤醒,让他进入 runnable 状态等待 cpu 调度。这就是典型的悲观锁的实现。独占锁是一种悲观锁,synchronized 就是一种独占锁,它假设最坏的情况,并且只有在确保其它线程不会造成干扰的情况下执行,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。
但是,由于在进程挂起和恢复执行过程中存在着很大的开销。当一个线程正在等待锁时,它不能做任何事,所以悲观锁有很大的缺点。举个例子,如果一个线程需要某个资源,但是这个资源的占用时间很短,当线程第一次抢占这个资源时,可能这个资源被占用,如果此时挂起这个线程,可能立刻就发现资源可用,然后又需要花费很长的时间重新抢占锁,时间代价就会非常的高。
乐观锁思路就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。某个线程可以不让出 cpu,而是一直 while 循环,如果失败就重试,直到成功为止。所以,当数据争用不严重时,乐观锁效果更好。比如 CAS 就是一种乐观锁思想的应用。
CAS(Compare and Swap)算法
CAS 中有三个核心参数:
主内存中存放的 V 值,所有线程共享。
线程上次从内存中读取的 V 值 A 存放在线程的帧栈中,每个线程私有。
需要写入内存中并改写 V 值的 B 值。也就是线程对 A 值操作后要放入到主存 V 中。
上面说的比较抽象,看下面的这幅图比较容易理解。
如上图中,主存中保存 V 值,线程中要使用 V 值要先从主存中读取 V 值到线程的工作内存 A 中,然后计算后变成 B 值,最后再把 B 值写回到内存 V 值中。多个线程共用 V 值都是如此操作。CAS 的核心是在将 B 值写入到 V 之前要比较 A 值和 V 值是否相同,如果不相同证明此时 V 值已经被其他线程改变,重新将 V 值赋给 A,并重新计算得到 B,,再比较 A 值和 V 值,如果相同,则将 B 值赋给 V 值。
如果不使用 CAS 机制,看看存在什么问题,假如 V = 1,现在 Thread1 要对 V 进行加 1,Thread2 也要对 V 进行加 1,首先 Thread1 读取 V = 1 到自己工作内存 A 中,此时 A = 1,假设 Thread2 此时也读取 V = 1 到自己的工作内存 A 中,分别进行加 1 操作后,两个线程中 B 的值都为 2,此时写回到 V 中时发现 V 的值为 2,但是两个线程分别对 V 进行加 1 处理,结果却只加了一次 1。
CAS 核心代码
1 | if(A == V){ |
上面的操作是原子操作,现在来看看如果两个线程同时要对 V 进行加 1 操作,并且使用上面的 CAS 机制后能不能获得正确结果。
① Thread1 和 Thread2 要对 V 进行加 1,Thread1 和 Thread2 同时读取 v 值并且对 V 执行加 1 操作。
初始值 v = 1,A = 0,B = 0。
② 假设 Thread1,Thread2 先读取 V 值赋给 A,并且对 A 进行加 1,得到 B = 2。
V = 1,T1_A = 1,T1_B = 2;T2_A = 1
Thread1 要将 T1_B 写入 V 中,先要执行 CAS 操作:
1 | if(T1_A == V){ |
因为 T1_A = 1 = V,所以执行 V = T1_B = 2,此时 V = 2。
③ Thread2 也要对 V 执行加操作。执行加操作之后
V = 2,T2_A = 1,T2_B = 2
当 Thread2 要将 T2_B 值写要 V 中之前要执行 CAS 操作,
1 | if(T2_A == V){ |
此时 T2_A = 1,V = 2, T2_A != V,这时候返回 V = 2,给 T2_A,T2_A = V = 2,然后再次对 T2_A 进行加 1,得到 T2_B,此时 T2_B = 3,T2_A = 2,比较 T2_A = V,所以将 T2_B 的值赋给 V,V = T2_B = 3。最后结果为 v = 3,正确。
CAS 的优缺点
优点:
- 竞争不大的时候系统开销小。
缺点:
- 循环时间长开销大。
- ABA 问题。
如果一开始位置 V 得到的旧值是 A,当进行赋值操作时再次读取发现仍然是 A,并不能说明变量没有被其它线程改变过。有可能是其它线程将变量改为了 B,后来又改回了 A。大部分情况下 ABA 问题不会影响程序并发的正确性,如果要解决 ABA 问题,用传统的互斥同步可能比原子类更高效。
- 只能保证一个共享变量的原子操作。
ABA问题的解决办法
- 在变量前面追加版本号:每次变量更新就把版本号加 1,则 A-B-A 就变成 1A-2B-3A。
- atomic 包下的 AtomicStampedReference 类:其 compareAndSet 方法首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用的该标志的值设置为给定的更新值。
Java 类加载机制
对象和对象的引用
对象
每个对象都是某个类(class)的一个实例(instance)。
对象的引用
1 | Person person = new Person("张三"); |
Java 用 new 关键字在堆上创建对象,这里的 new Person("张三");
就是在堆内存中创建了一个 Person 对象。Person person
声明了 Person
类的一个引用。
上面的代码等价于:
1 | Person person; |
引用变量和对象之间的 =
将 Person
对象的引用实际指向了一个 Person
类的实例。
从存储空间上来说,对象和引用也是独立的,它们存储在不同的地方,对象存储在堆中,而引用存储在速度更快的栈中。
对象的访问
在 Java 语言中,对象访问是如何进行的?对象访问在 Java 语言中无处不在,是最普通的程序行为,但即使是最简单的访问,也会却涉及 Java 栈、Java 堆、方法区这三个最重要内存区域之间的关联关系,如下面的这句代码:
1 | Object obj = new Object(); |
假设这句代码出现在方法体中,那 Object obj
这部分的语义将会反映到 Java 栈的本地变量表中,作为一个 reference 类型数据出现。而 new Object()
这部分的语义将会反映到 Java 堆中,形成一块存储了 Object 类型所有实例数据值(Instance Data,对象中各个实例字段的数据)的结构化内存,根据具体类型以及虚拟机实现的对象内存布局(Object Memory Layout)的不同,这块内存的长度是不固定的。另外,在 Java 堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。
由于 reference 类型在 Java 虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到 Java 堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针。
如果使用句柄访问方式,Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息,如下图所示。
如果使用直接指针访问方式,Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference 中直接存储的就是对象地址,如下图所示。
这两种对象的访问方式各有优势,使用句柄访问方式的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。就 Sun HotSpot 虚拟机而言,它是使用第二种方式进行对象访问的,但从整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。
System Properties
https://docs.oracle.com/javase/tutorial/essential/environment/sysprop.html
user.dir : User’s current working directory (用户当前的 工作目录/工程目录)
https://stackoverflow.com/questions/16239130/java-user-dir-property-what-exactly-does-it-mean
如果长时间无法加载,请针对 disq.us | disquscdn.com | disqus.com 启用代理