目录
带你全面了解高级 Java 面试中需要掌握的 JVM 知识点。 ——当年明月
@
JVM 内存划分与内存溢出异常
概述
如果在大学里学过或者在工作中使用过 C 或者 C++ 的读者一定会发现这两门语言的内存管理机制与 Java 的不同。在使用 C 或者 C++ 编程时,程序员需要手动的去管理和维护内存,就是说需要手动的清除那些不需要的对象,否则就会出现内存泄漏与内存溢出的问题。
如果你使用 Java 语言去开发,你就会发现大多数情况下你不用去关心无用对象的回收与内存的管理,因为这一切 JVM 虚拟机已经帮我们做好了。了解 JVM 内存的各个区域将有助于我们深入了解它的管理机制,避免出现内存相关的问题和高效的解决问题。
引出问题
在 Java 编程时我们会用到许多不同类型的数据,比如临时变量、静态变量、对象、方法、类等等。 那么他们的存储方式有什么不同吗?或者说他们存在哪?
运行时数据区域
Java 虚拟机在执行 Java 程序过程中会把它所管理的内存分为若干个不同的数据区域,各自有各自的用途。
程序计数器
线程私有的,可以看作是当前线程所执行字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
这时唯一一个没有规定任何 OOM 异常的区域。
虚拟机栈
虚拟机栈也是线程私有的,生命周期与线程相同。栈里面存储的是方法的
局部变量
、对象的引用
等等。在这片区域中,规定了两种异常情况,当线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常。当虚拟机栈动态扩展无法申请到足够的内存时会抛出 OOM 异常。
本地方法栈
和虚拟机栈的作用相同,只不过它是为 Native 方法服务。HotSpot 虚拟机直接将虚拟机栈和本地方法栈合二为一了。
堆
堆是 Java 虚拟机所管理内存中最大的一块。是所有线程共享的一块内存区域,在虚拟机启动时创建。这个区域唯一的作用就是存放
对象实例
,也就是 NEW 出来的对象。这个区域也是 Java 垃圾收集器的主要作用区域。当堆的大小再也无法扩展时,将会抛出 OOM 异常。
方法区
方法区也是线程共享的内存区域,用于存储已经被虚拟机加载的
类信息
、常量
、静态变量
等等。当方法区无法满足内存分配需求时,会抛出 OOM 异常。这个区域也被称为永久代。
补充
虽然上面的图里没有运行时常量池和直接内存,但是这两部分也是我们开发时经常接触的。所以给大家补充出来。
运行时常量池
运行时常量池是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种
字面量
和符号引用
,这部分内容将在类加载后存放到方法区的运行时常量池中。也会抛出 OOM 异常。直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,但是却是
NIO
操作时会直接使用的一块内存,虽然不受虚拟机参数限制,但是还是会受到本机总内存的限制,会抛出 OOM 异常。
JAVA8 的改变
对于方法区,它是线程共享的,主要用于存储类的信息,常量池,方法数据,方法代码等。我们称这个区域为永久代
。
大部分程序员应该都见过 java.lang.OutOfMemoryError:PermGen space 异常,这里的 PermGen space
其实指的就是方法区。由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代
的内存溢出,典型的场景是在 JSP 页面比较多的情况,容易出现永久代内存溢出。
在JDK 1.8中,HotSpot 虚拟机已经没有 PermGen space 这个区域了,取而代之的是一个叫做Metaspace
(元空间)的东西。
变化就是移除了方法区,增加了元空间,与方法区最大的区别是:元空间不再虚拟机中,而是使用本地内存。默认情况下,元空间的大小仅受本地内存限制。
这样更改的好处:
- 字符串常量存在方法区中,容易出现性能问题和内存溢出。
- 类和方法的信息等比较难确定大小,因此对于方法区大小的指定比较困难,太小容易出现方法区溢出,太大容易导致堆的空间不足。
- 方法区的垃圾回收会带来不必要的复杂度,并且回收效率偏低(垃圾回收会在下一章给大家介绍)。
内存溢出
虽然有 JVM 帮我们管理内存,但是在实际开发过程中一定还会遇到内存溢出的问题。堆,栈,方法区都有可能出现内存溢出问题。下面我们就结合几个实际的小例子来给大家展示一下,方便大家以后根据不同的情况对内存溢出问题进行快速准确的定位。
java.lang.OutOfMemoryError: Java heap space ———>java 堆内存溢出,此种情况最常见,一般由于内存泄露或者堆的大小设置不当引起。对于内存泄露,需要通过内存监控软件查找程序中的泄露代码,而堆大小可以通过虚拟机参数 -Xms、 -Xmx 等修改。
例子:在集合中无限加入对象,效果受到机器配置影响,可以主动更改堆大小方便演示。
public class HeapOOM { public static void main(String[] args){ long i= 0; try { List
70091068700910697009107070091071java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3210) at java.util.Arrays.copyOf(Arrays.java:3181) at java.util.ArrayList.grow(ArrayList.java:265) at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239) at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231) at java.util.ArrayList.add(ArrayList.java:462) at HeapOOM.main(HeapOOM.java:14)
java.lang.OutOfMemoryError: PermGen space ------>java永久代溢出,即方法区溢出了,一般出现于大量Class 或者 JSP 页面,或者采用 CGLIB 等反射机制的情况,因为上述情况会产生大量的 Class 信息存储于方法区。此种情况可以通过更改方法区的大小来解决,使用类似 -XX:PermSize=64m -XX:MaxPermSize=256m 的形式修改。另外,过多的常量尤其是字符串也会导致方法区溢出,因为常量池也是方法区的一部分。
例子:无限加载 Class,需要在 JDK 1.8 之前的版本运行,因为1.8将方法区改成了元空间,利用了机器的内存,最好手动设置 -XX:MaxPermSize,将值调小一点。
public class HeapOOM { public static void main(String[] args) throws Exception { for (int i = 0; i < 100_000_000; i++) { generate("cn.paul.test" + i); } } public static Class generate(String name) throws Exception { ClassPool pool = ClassPool.getDefault(); return pool.makeClass(name).toClass(); }}
结果大家自己试一下吧
java.lang.StackOverflowError ------> 不会抛 OOM error,但也是比较常见的 Java 内存溢出。Java 虚拟机栈溢出,一般是由于程序中存在死循环或者深度递归调用造成的,栈大小设置太小就会出现此种溢出。可以通过虚拟机参数 -Xss 来设置栈的大小。
例子:无法快速收敛的递归。
public class HeapOOM { public static void main(String[] args){ stackOverFlow(new AtomicLong(1)); } public static void stackOverFlow(AtomicLong counter){ System.out.println(counter.incrementAndGet()); stackOverFlow(counter); }}
8769877087718772877387748775Exception in thread "main" java.lang.StackOverflowError at java.lang.Long.toString(Long.java:396) at java.lang.String.valueOf(String.java:3113) at java.io.PrintStream.print(PrintStream.java:611) at java.io.PrintStream.println(PrintStream.java:750) at HeapOOM.stackOverFlow(HeapOOM.java:14) at HeapOOM.stackOverFlow(HeapOOM.java:15)
思考
看完上面的讲解后,相信大家对于 Java 中各种变量、类、方法、实例的存储位置都已经了解了。下面结合一些简单的面试题来加深一下大家的理解。
String a = new String("xyz");
问:这段代码创建了几个对象,都存在 JVM 中的哪个位置?
答:答案是两个对象,第一个是通过 NEW 关键字创建出来的 a 对象,它的存储位置当然是在堆中。第二个是 xyz 这个对象,它存在常量池中(String 在 Java 中被定义为不可变的对象,类的定义和方法都是 final 的,所以会被当作常量看待)。
问:a 对象的引用存在哪里?
答:对象的引用全部存在栈中。
问:Java 中各个对象、变量、类的存储位置?
答:如果你已经掌握了上面的内容,这个问题应该是不难的。NEW 出来的对象存储在堆中,局部变量和方法的引用存在栈中,类的相关信息、常量和静态变量存在方法区中,1.8以后使用元空间存储类相关信息。
问:Java 中会有内存溢出问题吗?发生在哪些情况下?
答:JVM 的堆、栈、方法区、本地方法栈、直接内存都会发生内存溢出问题。典型的堆溢出的例子:集合持有大量对象并且长期不释放。典型的栈溢出例子:无法快速收敛的递归。典型的方法区溢出例子:加载了大量的类或者 JSP 的程序。
垃圾回收算法与收集器
概述
上一篇文章我们已经了解了 Java 的这几块内存区域。对于垃圾回收来说,针对或者关注的是 Java 堆这块区域。因为对于程序计数器、栈、本地方法栈来说,他们随线程而生,随线程而灭,所以这个区域的内存分配和回收可以看作具备确定性。对于方法区来说,分配完类相关信息后内存大小也基本确定了,加上在 JAVA8 中引入的元空间,所以这个部分也不用关注。
方法区回收
很多人认为方法区是没有垃圾收集的,Java 虚拟机规范也确实说过可以不要求在虚拟机方法区实现垃圾收集,而且在这个地方收集性价比比较低。在堆中,一次可以回收70%~95%的空间,而方法区也就是永久代的回收效率远低于此。方法区垃圾收集主要回收两部分内容:废弃常量和无用的类。
JAVA8 引入的元空间很好的解决了方法区回收效率低下的问题。
引出问题
Java 堆中存储的是 NEW 出来的对象,那么什么样的对象是需要被垃圾回收器回收掉的那?可能你会回答不用的对象或者死掉的对象。那如何判断对象已经不用了或者死掉了那?怎么回收这些死掉了的对象那?
如何判断对象已死
引用计数器
每当有一个地方引用它时,计数器值就加一,引用失效时,计数器值减一。简单高效,但是没办法解决循环引用的问题。
可达性分析算法
这个算法的基本思路就是通过一系列名为 GC ROOTS 的对象作为起始点,从这些节点开始向下搜索。当一个对象到 GC ROOTS 没有任何引用链接时,则证明此对象时不可用的。
可以作为 GC ROOTS 的对象包括下面几种:
- 方法里引用的对象。
- 方法区中的类静态属性引用的对象。
- 方法区中的常量引用的对象。
- 本地方法中引用的对象。
HotSpot 虚拟机采用的是可达性分析算法。
如何回收
当前的商业虚拟机的垃圾收集都采用分代垃圾回收的算法,这种算法并没有什么新的思想。只是根据对象的存活周期将不同的内存划分为几块。一般是把 Java 堆分为新生代
和老年代
,根据新生代和老年代存活时间的不同采取不同的算法,使虚拟机的 GC 效率提高了很多。新生代采用复制算法,老年代采用标记-清除或者标记-整理算法。
回收算法
标记-清除
算法分为
标记
和清除
两个阶段,首先要标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。缺点:效率问题,标记和清除过程的效率都不高,另外会有不连续的内存碎片。
复制
为了解决效率问题,复制算法出现了,将内存按容量划分为大小相等的两块,每次只使用其中的一块。清除后将活着的对象复制到另外一块上面。简单高效。现在的商业虚拟机都采用这种收集算法来回收
新生代
。因为新生代
中的对象98%都是朝生夕死的,所以并不需要按1:1划分内存,而是按8:1:1分为 Eden,survivor,survivor。每次只使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活的对象一次性拷贝到另外一块 Survivor 上。当 Survivor 空间不够用时,需要依赖
老年代
进行分配担保。比较适合需要清除的对象比较多的情况。
(图片来源于网络)
标记-整理
标记-整理算法和标记-清除算法的标记过程一样,后序有一个对内存进行整理的动作。和标记-整理算法一样,比较适合要清除对象不多的情况。复制算法在对象存活率较高时就要执行较多的复制操作,效率会变的很低。而且如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对对象 100% 存活的极端情况,所以
(图片来源于网络)老年代
一般不选复制算法,而选择标记-清除或者标记-整理算法。
内存分配与回收策略
首先需要了解两个名词:
Minor GC:新生代 GC,指的是发生在新生代的垃圾回收动作,因为 Java 对象大多都具有朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
Full GC:老年代 GC,出现了 Full GC,一般也会伴随至少一次的 Minor GC(非绝对的)。
对象的内存分配,往大了讲就是在堆中分配。下面是更细粒度的分配策略。
- 对象优先在 Eden 中分配,大多数情况下,对象在新生代 Eden 中分配,当 Eden 没有足够的空间进行分配时,虚拟机将发起一次 Minor GC。在 GC 开始的时候,对象只会存在于 Eden 区和名为 From 的 Survivor 区,名为 To 的 Survivor 区是空的。紧接着进行 GC,Eden 区中所有存活的对象都会被复制到 To,而在 From 区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过 -XX:MaxTenuringThreshold 来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到 To 区域。经过这次 GC 后,Eden 区和 From 区已经被清空。这个时候,From 和 To 会交换他们的角色,也就是新的 To 就是上次 GC 前的 From ,新的 From 就是上次 GC 前的 To 。不管怎样,都会保证名为 To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程,直到 To 区被填满,To 区被填满之后,会将所有对象移动到年老代中。
- 大对象直接进入老年代,大对象指的是那些需要连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组。直接进入老年代避免了大对象在 Eden 区和 Survivor 区之间发生大量的内存拷贝。
- 长期存活的对象将进入老年代,虚拟机给每个对象定义了一个对象年龄计数器,如果对象在 Eden 出生并经过一次 Minor GC 后仍然存活,并且能被 Survivor 容纳就会被移动到 Survivor 中,并且年龄增加 1。当年龄达到某个阙值(默认为 15)时,就会晋升到老年代。
垃圾收集器
如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。下面介绍基于 HotSpot 虚拟机中的垃圾收集器。对于垃圾收集器,大家有个概念就可以了,没有必要去深究垃圾收集器的底层原理,当然如果有余力,了解底层原理当然是最好的。
(图片来源于网络)
Serial 收集器
最早的垃圾收集器,回收新生代,单线程。这里的单线程不仅仅说明它只会使用一个 CPU 或者一条收集线程去完成垃圾收集工作,重要的是,在进行垃圾收集时,必须暂停其他所有工作线程(Stop The World)。
ParNew 收集器
新生代垃圾回收,ParNew 收集器其实就是 Serial 收集器的多线程版本,在收集算法,Stop The World 和对象分配规则,回收策略上都与 Serial 相同。ParNew 在单核甚至双核 CPU 上的表现不如 Serial,更多的 CPU 才能体现他的优点。
Parallel Scanvnge 收集器
新生代垃圾回收,采用复制算法,关注吞吐量,不关注停顿时间。停顿时间越短就越适合需要于用户交互的程序,良好的响应速度能提升用户的体验。高吞吐量则可以最高效率地利用 CPU 时间,尽快完成运算任务,适合在后台运算而不需要太多交互的任务。
Serial Old 收集器
Serial 的老年代版本,单线程,使用标记-整理算法。
Parallel Old 收集器
Parallel New 的老年代版本,使用标记-整理算法。
CMS 收集器
CMS 是一种以获取最短回收停顿时间为目标的收集器,注重响应速度。基于标记-清除算法实现的。不同于其他收集器的全程 Stop The World,CMS 会有两次短暂的 Stop The World,垃圾收集和工作线程并发执行。整个过程分为 4 个步骤:
- 初始标记(Stop The World),标记 GC Roots 能关联到的对象。
- 并发标记
- 重新标记(Stop The World)
- 并发清除
G1 收集器
基于标记-整理实现。可以实现在基本不牺牲吞吐量的前提下完成低停顿的内存回收,新生代和老年代都可以回收。
思考
问:JVM 中使用了什么算法进行垃圾回收?
答:根据对象的存活时间采取了分代垃圾回收算法。新生代采取了复制算法(面试时可以就对象的分配以及 Eden、Survivor、Survivor 继续说一些),老年代采取了标记-清除或标记-整理算法。
问:如何判断对象已死?
答:引用计数器和可达性分析算法,HotSpot 虚拟机采取了可达性分析算法。
问:你了解哪些垃圾收集器?他们有什么区别?
答:新生代的有 Serial(单线程),ParNew(Serial 的多线程版本),PS(比较注重吞吐量)。老年代有 Serial Old(单线程),Parallel Old(ParNew 的老年代版本),CMS(两次Stop The World,实现了并发清除)。G1(基本不牺牲吞吐量的前提下完成低停顿的内存回收,新生代和老年代都可以回收)。
虚拟机中的类加载机制
概述
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换、解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
类的生命周期
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用、卸载七个阶段。其中验证、准备和解析三个部分统称为连接。
(图片来源于网络)
加载:加载是类加载的第一个阶段,这个阶段,首先要根据类的全限定名来获取定义此类的二进制字节流,将字节流转化为方法区运行时的数据结构,在 Java 堆生成一个代表这个类的 java.lang.class 对象,作为方法区的访问入口。
验证:这一步的目的时确保 Class 文件的字节流包含的信息符合当前虚拟机的要求。
准备:准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都会在方法区中进行分配。仅仅是类变量,不包括实例变量。
public static int value = 123;
变量在准备阶段过后的初始值为0而不是123,123的赋值要在变量初始化以后才会完成。
解析:虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化:初始化是类加载的最后一步,这一步会根据程序员给定的值去初始化一些资源。
什么时候加载
对于什么时候进行类的加载,虚拟机规范中并没有进行强制约束。但是以下几种情况时,必须对类进行初始化(加载、验证、准备则肯定要在此之前完成)。
- 遇到 new,getstatic,putstatic 或 invokestatic 这四条字节码指令时,如果没有初始化则要先触发其初始化。生成这4条指令的 Java 代码场景是:使用 new 关键字实例化对象的时候,读取或者设置一个类的静态字段,或调用一个类的静态方法时。
- 使用 java.lang.reflect 包进行反射调用时,如果没有初始化,则先要进行初始化。
- 当初始化一个类的时候,发现其父类还没被初始化,则需要先触发父类的初始化。
- 虚拟机启动时,用户需要指定一个执行的主类(包含 main 方法的类),虚拟机会先初始化这个主类。
这四种场景称为对一个类进行主动引用,除此之外所有引用类的方式都不会出发初始化。
下面演示两个被动使用类字段的例子,通过子类引用父类的静态字段,不会导致子类初始化:
class SuperClass{ static{ System.out.println("super init"); } public static int value = 123;}class SubClass extends SuperClass{ static{ System.out.println("sub init"); }}public class Show{ public static void main(String[] args){ System.out.println(SubClass.value); }}//输出结果super init123
常量在编译阶段会存入调用类的常量池,本质上没有直接应用到定义常量的类,因此不会使定义常量的类的初始化,这段代码运行后不会输出 ConstClass init ,因为虽然在 Java 源码中引用了 ConstClass 类中的常量 HELLOWORLD,但是在编译阶段这个值就已经被存到了常量池中,对 ConstClass.HELLOWORLD 的引用实际都转化为了 Show 类对自身常量池的引用了。这两个类在编译成 Class 之后就不存在任何联系了。
class ConstClass{ static{ System.out.println("ConstClass init!"); } public static final String HELLOWORLD = "hello world";}public class Show{ public static void main(String[] args){ System.out.println(ConstClass.HELLOWORLD); }}//定义常量的类并没有初始化hello world
接口有一点不同,当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有真正使用到了父接口才会初始化。
类加载器
虚拟机设计团队把类加载阶段中的通过一个类的全限定名来获取描述此类的二进制字节流这个动作放到 Java 虚拟机外部去实现,以便让程序自己去决定如何获取所需要的类,这个动作的代码模块称为类加载器
。
对于一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,比较两个类是否相等需要在这两个类是由同一个类加载器加载的前提下才有意义。
双亲委派模型
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。只有当父类加载器反馈自己无法完成这个加载请求(他的搜索范围中没有找到所需的类)时,子类加载器才会尝试去加载。
好处:使用双亲委派模型的好处是,Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,比如 java.lang.Object,它存放在 rt.jar 中,无论哪一个类加载器要加载这个类,最后都是委派给启动类加载器进行加载。
如果不使用双亲委派模型,用户自己写一个 Object 类放入 ClassPath,那么系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无从保证。
现在你可以尝试自己写一个名为 Object 的类,可以被编译,但永远无法运行。因为最后加载时都会先委派给父类去加载,在 rt.jar 搜寻自身目录时就会找到系统定义的 Object 类,所以你定义的 Object 类永远无法被加载和运行。
Java 虚拟机的类加载器可以分为以下几种:
(图片来源于网络)
- 启动类加载器(Bootstrap ClassLoader):这个类负责将 \lib 目录中的类库加载到内存中,启动类加载器无法被Java程序直接饮用。
- 扩展类加载器(Extension ClassLoader):负责加载 \lib\ext 目录中的类。开发者可以直接使用扩展类加载器。
- 应用程序类加载器(Application ClassLoader):这个类加载器是 ClassLoader 中 getSystemClassLoader() 方法的返回值,所以一般称为系统类加载器。如果没有自定义过加载器,一般情况下这个就是默认的类加载器。
- 自定义类加载器(User ClassLoader):通过自定义类加载器可以实现一些动态加载的功能,比如 SPI。
Java 内存模型与线程
概述
了解 JVM 的 Java 内存模型以及结构对于我们在多线程开发时有很大帮助。了解线程安全的虚拟机底层运作原理以及虚拟机实现高效并发所采取的一些列锁优化措施是我们开发高效和安全代码的基础。
通过硬件类比 Java 内存模型
硬件效率一致性
计算机的存储设备(内存,磁盘)和处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存和处理器之间的缓冲。
将运算所需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后在从缓存同步回内存中,这样处理器就无需等待缓慢的内存读写了。
这时会有缓存一致性问题,在多处理器系统中,每个处理器都有自己的高速缓存,他们又共享同一主内存,会有可能导致各自的缓存数据不一致的问题。为了解决这个问题,需要根据一些读写协议来操作,比如 MSI、MESI、MOSI、Synapse 等等。
在硬件系统中,为了保证处理器内部的运算单元被充分利用,处理器可能会对输入代码进行乱序执行优化。Java 虚拟机即时编译器也有类似的指令重排序优化。
Java 内存模型
Java 虚拟机规范中试图定义一种 Java 内存模型( Java Memory Model )来屏蔽掉各种硬件和操作系统的内存访问差异,让 Java 在各种平台下都能达到一致的并发效果。
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量是指实例字段,静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为后者是线程私有的,不会被共享。
(图片来源于网络)Java 内存模型规定了所有变量都是存储在主内存(Main Memory)中。每条线程还有自己的工作内存,工作内存中保存了被改线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。线程间的通信要通过工作内存进行。工作内存中更改的变量会不定时刷新到主存中。
通过对比发现,二者的变量更改、数据共享、内存刷新以及架构都非常相似。
volatile 与特殊规则
volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,定义成 volatile 的字段能保证此变量对所有线程的可见性,修改后立刻刷新到主存,其他线程读取这个变量也要在主存中读取。volatile 可以禁止指令重排序优化。
通过上面的 Java 内存模型和 volatile 立即刷新和避免指令重排序的特性可以发现 volatile 可以保证数据的可见性。但是它不能保证原子性。
对于 64 位的数据类型,在模型中规定,它允许将没有被 volatile 修饰的 64 位数据的读写划分为两次的 32 位来操作,即不保证他的原子性。不过目前各种平台的商用虚拟机机会都选择把 64 位的数据读写作为原子操作来对待,因为不需要专门为 long 和 double 声明 volatile。
Java 与线程
并发不一定要依赖多线程(PHP 常见的多进程并发),但是在 Java 里面谈论并发,大多数都与线程脱不开关系。
线程是比进程更轻量级的调度单位,线程可以把一个进程的资源分配和调度执行分开,各个线程既可以共享进程资源,又可以独立调度。
Java 中写一个线程有三种方式,继承 Thread 类,实现 Runnable 接口,实现 Callable 接口。对于 Sun JDK 来说,它的 Windows 与 Linux 版都是使用一对一的线程模型来实现的,一条 Java 线程就映射到一条轻量级进程之中。
状态转换
Java 定义了 5 种线程状态,一个线程有且仅有其中一种状态。
(图片来源于网络)
- 新建(new):创建后尚未启动的线程就处于这种状态。
- 运行(Runnable):线程正在运行的状态。
- 就绪(Ready):就绪状态,等待 CPU 分配时间片后就可以运行。
- 阻塞(Blocked):可能是因为调用了 wait 方法进入阻塞(释放锁),也可能是 sleep 方法进入阻塞(不释放锁)。
- 结束(Terminated):以终止线程的线程状态,线程已经结束执行。
线程安全
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方法进行任何其他协同操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。 按照线程安全由强至弱来排序,我们可以将 Java 语言中各种操作共享的数据分为以下五类:
不可变
在 JDK 1.5 以后,不可变(Immutable)的对象一定是线程安全的,无论是对象的方法还是方法的调用者,都不需要再进行任何的线程安全保证措施。对于基本数据类型,使用 final 关键字修饰它就可以保证它是不可变的。
如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行。比如 java.lang.String 类,它是典型的不可变对象,我们调用它的 substring() ,replace() ,concat() 方法不会影响它原来的值,只会返回一个新构造的字符串对象。
绝对线程安全
绝对线程安全的定义是,一个类要达到不管运行时环境如何,调用者都不需要任何额外的同步措施。满足这个要求很难。比如 java.util.Vector 是一个线程安全的容器,它的 add()、get()、 size()等方法都被 synchronized 修饰。但是多线程对它同时操作时,它可能也不那么安全。
相对线程安全
相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。Java 中大部分的线程安全类都属于这个类型,比如 Vector,HashTable,Collections 的 synchronizedCollection() 方法包装的集合等。
线程兼容
线程兼容指的是对象本身并不是线程安全的,但是通过在调用端正确的同步手段来保证对象在并发环境中安全的使用。平时说一个类不是线程安全的,绝大多数指的都是这种情况。比如 Vector 和 HashTable 对应的 ArrayList 和 HashMap 类。
线程对立
线程对立是指不管调用端是否采用了同步措施,都无法在多线程环境中并发使用代码。这种代码多数是有害的,应当尽量避免。比如 Thread 类的 suspend() 和 resume() 方法,如果有两个线程同时持有一个对象,一个尝试去中断线程,一个尝试去恢复线程,如果并发进行,目标线程是存在死锁风险的,所以这两个方法已经废弃了。
线程安全的实现方法
阻塞同步
Java 中最基本的同步手段就是 synchronized 关键字,synchronzied 关键字在经过编译后,会在代码块前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。这两个字节码需要一个对象来指明要锁定和解锁的对象。如果 synchronized 明确指定了对象参数,那么锁的就是这个对象,如果 synchronized 修饰的是方法和类,那么锁的就是对象实例或 Class 对象作为锁对象。
synchronized 同步快对于已经获得锁的同一条线程来说是可重入的,不会出现锁死自己的问题。另外,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。
由于 Java 的线程是映射到操作系统的原生线程之上的,如果阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到内核态,这个状态转换需要耗费很多的处理器时间。对于代码简单的同步快,状态转换消耗的时间可能比代码执行时间还长。所以 synchronized 是一个重量级锁。
除了 synchronized 之外,还可以是用 JUC 包中的重入锁(ReentrantLock,Lock 接口的实现类)来实现同步。与 synchronized 相同的是,它具备线程重入的特性。ReentrantLock 表现为 API 层面的互斥锁,synchronized 是 JVM 底层实现的互斥锁。Lock 接口的高级功能:
等待可中断 指的是当前持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这对于处理时间非常长的同步块有很大的帮助。
公平锁 指的是多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获取锁。synchronized 是非公平的。
绑定多个条件 指的是一个 Lock 对象可以同时绑定多个 Condition 对象。
非阻塞同步
阻塞同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,属于悲观的并发策略。另一种选择就是基于冲突检测(CAS)的乐观并发策略,通俗的讲就是,先进行操作,如果没有其他线程争抢,那操作就成功了。
乐观的并发策略需要硬件指令集来完成的,用硬件来保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成。
测试并设置(Test-and-Set)
获取并增加(Fetch-and-Increment)
交换(Swap)
比较并交换(Compare and Swap,CAS)
CAS 指令需要三个操作数,分别是内存位置 V,旧的预期值 A 和新值 B 。当且仅当 V 符合预期值 A 时,处理器用新值 B 更新 V 的值,否则就不更新。AtomicInteger 等类就是通过 CAS 实现的。
无同步方案
要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无需任何同步措施去保证正确性。
可重入代码:所有可重入代码都是线程安全的,在代码执行任何时刻中断它,转而去执行另外一段代码,返回后也不会出现任何错误。简单来说就是输入了相同的数据,就能返回相同的结果。
线程本地存储:把共享数据的可见范围限制在同一个线程之内,这样,无需同步也能保证线程之间不出现数据争用的问题。比如大部分的”生产者-消费者”模型,还有 Web 交互模型中的一个请求对应一个服务器线程(Thread-per-Request)的处理方式。
Java 中有 ThreadLocl 类来实现线程本地存储的功能。key 是线程,value 是线程的本地化值。
锁优化
synchronized 锁是一个重量级锁,在 JDK 1.5 时对它进行了优化。
自旋锁与自适应自旋
为了避免线程的挂起和恢复带来的性能问题,可以让后面请求锁的那个线程等一会,不放弃处理器的执行时间,看看持有锁的线程是否很快就释放锁,让等待线程执行一个忙循环(自旋)。
自适应自旋意味着自旋时间不固定了,而是由前一次在同一个锁上自旋时间以及锁拥有者的状态来决定,动态的确定自旋时间。
偏向锁
大多数时候是不存在锁的竞争的,常常是一个线程多次获得同一个锁,为了减少每次竞争锁的代价,引入偏向锁。
当线程1访问代码块并获取锁对象时,会在 Java 对象头和栈帧中记录偏向的锁的 threadID。因为偏向锁不会主动释放,所以当线程1在此想获取锁的时候,返现 threadID 一致,则无需使用 CAS 来加锁,解锁。如果不一致,线程2需要竞争锁,偏向锁不会主动释放里面还是存储线程1的 threadID。如果线程1没有存活,那么锁对象被重置为无锁状态,其他线程竞争并将其设置为偏向锁。如果线程1 还存活,那么查看线程1是否需要持有锁,如果需要,升级为轻量级锁。如果不需要设置为无锁状态,重新偏向。
轻量级锁
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景,为了避免阻塞线程让 CPU 从用户态转到内核态和代价,干脆不阻塞线程,直接让它自旋等待锁释放。
线程1获取轻量级锁时会先把锁对象的对象头复制到自己的锁记录空间,然后使用 CAS 替换对象头的内容。
如果线程1复制对象头的同时,线程2也准备获取锁,但是线程2在 CAS 的时候失败,自旋,等待线程1释放锁。
如果自旋到了次数线程1还没有释放锁,或者线程1在执行,线程2在自旋等待,这时3有来竞争,这个轻量级锁会膨胀为重量级锁,重量级锁把所有拥有锁的线程都阻塞,防止 CPU 空转。
虚拟机性能监控与故障处理工具
概述
对一个系统问题定位时,数据是依据,工具是运用知识处理数据的手段。这里的数据包括:运行日志,异常堆栈,GC 日志,线程快照,堆转储快照等等。通过这些数据,我们可以快速定位 JVM 发生问题的位置,快速的解决它。
JDK 命令行工具
在 JDK 的 bin 目录,除了 Java 和 Javac,还有一些比较好用的 JDK 工具帮我们去定位系统问题。实际上他们都是对 tools.jar 类库里面的接口的简单封装,这些命令可以让你在应用程序中实现功能强大的监控分析功能。
jstat:虚拟机统计信息监视工具
JVM Statistics Monitoring Tool 是用于监视虚拟机总运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集,JIT 编译等运行数据。
下面演示用 -gcutil 来监视堆内存状况。
bin>jstat -gcutil 2764S0 S1 E O P YGC YGCT FGC FGCT GCT0.00 0.00 6.20 41.42 47.20 16 0.105 3 0.472 0.577
结果表明:这台服务器的 Eden 区(E,表示 Eden)使用了6.2%的空间,两个 Survivor 区(S0,S1)里面都是空的,老年代(O,表示 Old)和永久代(方法区,P表示 Permanent)则分别使用了41.42%和47.20%的空间。程序运行以来共发生 Minor GC 16次,Full GC 3次。
jmap:Java 内存映像工具
Memory Map for Java 命令用于生成堆转储快照(一般称为 heapdump 或者 dump 文件)。jmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalize 执行队列,Java 堆和永久代的详情。
下面是用 jamp 生成一个正在运行的 Eclipse 的 dump 快照文件的例子,3500是通过 jps 命令查询到的 LVMID。
bin> jamp -dump:format=b,file=eclipse.bin 3500 Dumping heap to
jhat:虚拟机堆转储快照分析工具
JVM Heap Analysis Tool 命令一般与 jamp 搭配使用,来分析 jamp 生成的堆转储快照。实际情况中使用比较少,因为他的整体功能比较简陋。有一些专业的分析工具比较好用,比如 VisualVM,Eclipse Memory Analyzer。
jstack:Java 堆栈跟踪工具
Stack Trace for Java 命令用于生成虚拟机当前线程的快照(一般为 threaddump 或 javacore 文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合。目的是定位线程长时间停顿的原因,比如线程死锁,死循环,请求资源时间过长等。
通过这些封装好的命令,完全可以自己实现一个虚拟机运行监控的小系统。大部分公司都有自己的 JVM 内存监控系统,实现原理也是调用这几个命令。Github 上有许多比较好的实现,大家可以参考参考。
由于 markdown 编辑器注脚语法的 Bug 总也调不好,所以就把他们当作名词解释放在最后了。
HotSpot: 遵循 Java 虚拟机规范的商用虚拟机有很多,HotSpot 虚拟机是 Open JDK 中所使用的虚拟机,也是目前使用最广泛的。
Java Native Method: Java Natvie Method是 Java 调用一些本地方法的接口,所谓本地方法指的是用其他语言实现的方法,比如 C 或者 C++。因为某些操作系统底层的操作 Java 确实不如 C 或者 C++ 做的好。
StopTheWorld:顾名思义就是停止所有操作,垃圾收集器在进行垃圾回收时,需要停止其他所有工作线程,让垃圾收集器回收死掉的对象。
SPI:SPI 全称 Service Provider Interface,是Java提供的一套用来被第三方实现或者扩展的 API,它可以用来启用框架扩展和替换组件。