本笔记大部分是《深入理解java虚拟机第二版》的笔记,该书基于java1.7
Override
- 准确式内存管理:即虚拟机可以知道内存中某个位置的数据具体是什么类型
运行时数据区域
方法区
线程共享
存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等
虚拟机规范把方法区描述为堆的一个逻辑部分(但是有一个别名:Non-Heap)
HotSpot用永久代实现。其他虚拟机并没有永久代这个概念
但使用永久代来实现方法区,现在看来并不是一个好主意,因为这样更容易遇到内存溢出问题(永久代有
-XX:MaxPermSize
的上限,J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB,就不会出现问题),而且有极少数方法(例如String.intern()
)会因这个原因导致不同虚拟机下有不同的表现。规范要求,可以不需要连续的内存(连续的的物理内存?),可以固定大小或可扩展,可以不实现垃圾收集
有OutOfMemoryError异常
- 运行时常量池
- 方法区的一部分
- class文件中的常量池在类加载后就放入这里
- 规范没有做细节要求。一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中
- 具有动态性。运行期可以把新的常量放进池中(比如
String.intern()
)
直接内存
不是虚拟机运行时数据区的一部分,也不是规范中定义的内存区域
NIO可以直接操作堆外的内存
在JDK1.4中新加入了NIO(NewInput/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
可能出现OOM异常
这里的内存溢出,明显的特征是HeapDum文件中不会看到明显的异常,并且可能Dump文件很小
- 堆
- 存放对象实例
- 垃圾收集器管理的主要区域
- 线程共享。虚拟机启动时创建
- 可以划分出多个线程私有的分配缓冲区
- 有OutOfMemoryError异常
- 规范要求,可以处于物理上不连续的内存空间,只要逻辑上连续即可。可以是可扩展或固定大小
- 虚拟机栈
- 线程私有。生命周期与线程相同
- 栈帧中有:局部变量表(方法运行期不会改变大小,存有基本数据类型、对象引用、returnAddress类型)、操作数栈、动态链接、方法出口等信息
- 方法调用对应于一个栈帧在虚拟机栈的入栈到出栈过程
- 有StackOverflowError异常、OutOfMemoryError异常(只有可以动态扩展虚拟机栈的虚拟机再有OOM异常)
- 本地方法栈
- 类似于虚拟机栈
- 线程私有、生命周期与线程相同
- 具体的虚拟机可以自由实现
- 有StackOverflowError和OutOfMemoryError异常
- 在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈
- 程序计数器
- 线程私有、生命周期与线程相同
- 没有任何OutOfMemoryError情况
HotSpot在Java堆中对象的创建、布局、访问
创建
以下仅限于普通对象,不包括数组和class对象
- 当遇到new指令时
- 在常量池定位这个类的符号引用,并检查该类是否已被加载、解析和初始化过。如果没有要执行对应的加载过程
- 为新生对象分配内存(指针碰撞、空闲列表、TLAB)(在类加载后对象所需的内存便可完全确定)
- 把分配到的内存空间都初始为0值(如果是TLAB,可以提前到TLAB分配时进行)
- 设置对象头(哪个类的实例、如何找到类的元数据信息、对象的hashCode、对象的GC分代年龄等。根据是否启用偏向锁,对象头会有不同的设置方式)
- 截止以上,从虚拟机的角度看,新对象已经产生了
- 一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行
<init>
方法, 把对象按照程序员的意愿进行初始化, 这样一个真正可用的对象才算完全产生出来
布局
- 对象在内存中的布局有:对象头、实例数据、对齐填充
- 对象头
- 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
- 第二部分是类型指针,即指向类元数据的指针
- 如果是数组,还要有一块用于记录长度
- 实例数据
- 程序代码中所定义的各种类型的字段内容(包括从父类继承和子类中定义的)
- 存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle) 和字段在Java源码中定义顺序的影响
- 默认的分配策略是
- 相同宽度字段被分配在一起
longs/doubles
ints
shorts/chars
bytes/booleans
oops
(ordinary object pointers)(有个问题,指针不是64bit吗?)
- 满足上面那个条件后,父类定义的出现在子类之前
- 如果CompactFields参数值为true(默认为true),那么子类之中较窄的变量也可能会插入到父类变量的空隙之中
- 相同宽度字段被分配在一起
- padding
- 由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,所以可能需要padding
定位和访问
- HotSpot使用直接指针。另一种方式是句柄(不是HotSpot采用的)
- 句柄
- 需要在java堆中有句柄池
- 句柄中包含对象的实例数据和类型数据的各自具体地址
- 在对象被移动时,无需更新reference数据
类加载的时机
注意,这一部分JVM有明确规定,但不知道是否有一些是实现相关的。我试验时用的是HotSpot,openJDK 1.8,client模式下
按照作者说法
常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
在实验中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class InitTest {
public static void main(String[] args) {
System.out.println(Holder.str3);
// 如果是输出str,则不会输出`Init Holder`
// 如果是输出str2和str3则会
}
static class Holder {
public final static String str = "Hello";
public final static String str2 = new String("NewString");
public final static String str3 = String.valueOf("Str3");
static {
System.out.println("Init Holder");
}
}
}
HotSpot VM的结构
- GC(可插拔)
- JIT(可插拔)
- runtime
HotSpot Runtime
职责
- parsing of command line arguments
- VM life cycle
- class loading,
- byte code interpreter
- exception handling
- synchronization
- thread management
- Java Native Interface
- VM fatal error handling
- C++ (non-Java) heap management.
GC
- 程序计数器、虚拟机栈、本地方法栈3个区域需要的内存基本上在类结构确定时就已知(JIT优化会有所改变),并且生命周期与线程、方法的进入退出相同,所以内存回收是确定的
- java堆和方法区需要的内存则需要运行时才知道,分配回收都是动态的,所以需要GC
- 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
GC的3件事
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
可作为CG Roots的对象
- 虚拟机栈(栈帧中的本地变量表) 中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般说的Native方法) 引用的对象
强、软、弱、虚引用
- 强引用:只要还存在,GC永远不会回收被引用对象
- 软引用:对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常
- 弱引用:被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
- 虚引用:也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知
Hotspot GC
Minor GC:新生代的GC,Eden空间的存活对象和from survivor空间的不够老的存活对象复制到to survivor空间,from survivor空间中足够老的对象提升为老年代。结束后,Eden空间一般是全空的(也有不空的情况)。只要追求速度,空间利用率会低一些
Major GC(Full GC):老年代GC, Major GC经常会伴随至少一次的Minor GC(但非绝对的, 在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程) 。 Major GC的速度一般会比Minor GC慢10倍以上
java performance HotSpot中译本P57注释有
“实际上,HotSpot VM的FullGC收集整个堆,包括新生代、老年代、永生代”
过早提升:Minor GC时,to survivor空间不足,所以多余对象移到老年代
提升失败:Minor GC过程中,老年代满了,所以需要Full GC(这需要遍历整个java堆)
大对象可能直接分配到老年代
各种GC
Serial
- 单线程Stop-The-World式收集器
- 新生代使用复制收集算法(如同上面MinorGC那里描述的那样)
- 可以与CMS配合
- 应用场景
- 如果一台机器上有多个JVM(可能比CPU核数还多),那么使用Serial就很好。
- client模式下新生代的默认收集器(jdk7时?)
Serial Old
单线程Stop-The-World式收集器
Serial的老年代版本
使用标记清除压缩收集算法,回收时找出所有的存活对象,然后滑动到堆的头部
应用场景
如果一台机器上有多个JVM(可能比CPU核数还多),那么使用Serial Old就很好。
主要给Client模式下的虚拟机用
在Server模式下,那么它主要还有两大用途
一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使用
Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非直接使用了Serial Old收集器,但是这个PS MarkSweep收集器与Serial Old的实现非常接近,所以在官方的许多资料中都是直接以Serial Old代替PS MarkSweep进行讲解
另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用
ParNew:
- Serial的多线程版本(除了多线程,与Serial差别没有差别)
- 新生代的收集器
- 可以与CMS配合
- 应用场景
- 运行于Server模式下的首选新生代收集器(jdk7时?),因为除了serial,只有它能与CMS配合
Parallel Scavenge
- 又叫吞吐量优先处理器
- 新生代的收集器
- 多线程Stop-The-World式收集器,采用复制收集算法
- Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量
- 不能与CMS配合
- 自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别
- Parallel Scavenge收集器没有使用传统的GC收集器代码框架,而另外独立实现,其余几种收集器则共用了部分的框架代码
-XX:MaxGCPauseMillis
:参数设置最大垃圾收收集停顿时间。GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的,设得过小可能导致频繁GC-XX:GCTimeRatio
:设置吞吐量,为大于0小于100的整数,设为$x$的含义是GC时间占$\frac{1}{x+1}$,默认是99-XX:+UseAdaptiveSizePolicy
:打开后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例、晋升老年代对象年龄等细节参数了
Parallel Old
- Parallel Scavenge收集器的老年代版本
- 使用多线程和“标记-整理”算法
- 这个收集器是在JDK 1.6中才开始提供的,以前只能让Parallel Scavenge与Serial Old配合
- 在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器
CMS(Concurrent Mark Sweep)
老年代的收集器
以获取最短回收停顿时间为目标的收集器
使用多线程、标记-清除算法
它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作
尽可能并发、低停顿式的收集器,独立于HotSpot分代式GC框架另行实现的并行收集器
整个过程分为四个步骤:初始标记、并发标记、重新标记、并发清除
初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC RootsTracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作
缺点
对CPU资源敏感(默认启动的回收线程是$(\text{CPU数量}+3)/4$)
由于在垃圾收集阶段用户线程还需要运行,所以无法处理浮动垃圾,可能出现
Concurrent Mode Failure
失败而导致另一次FullGC要是CMS运行期间预留的内存无法满足程序需要,就会出现一次
Concurrent Mode Failure
失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用(可以通过
XX:CMSInitiatingOccupancyFraction
调高触发百分比,不过如果过高,就会出现Concurrent Mode Failure
)基于标记-清除算法,所以会有大量空间碎片,导致可能提前触发FullGC
- 为了解决这个问题,CMS收集器提供了一个
XX:+UseCMSCompactAtFullCollection
开关参数(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长 - 虚拟机设计者还提供了另外一个参数
-XX:CMSFullGCsBeforeCompaction
,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。
- 为了解决这个问题,CMS收集器提供了一个
G1
- G1收集器没有使用传统的GC收集器代码框架,而另外独立实现,其余几种收集器则共用了部分的框架代码
- 面向服务端
类加载
在实际情况中, 每个Class文件都有可能代表着Java语言中的一个类或接口, 后文中直接对“类”的描述都包括了类和接口的可能性
链接
- 在Java语言里面,类型的加载、 连接和初始化过程都是在程序运行期间完成的, 这种策略虽然会令类加载时稍微增加一些性能开销, 但是会为Java应用程序提供高度的灵活性,例如在运行时才指定某个接口的实现类
- 验证、准备、解析三个部分统称为连接
生命周期
- 加载(loading)
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
ClassLoader例子
1 | private final ClassLoader myLoader = new ClassLoader() { |
参数
以下可能有HotSpot专有的参数
-Xms
: the initial memory sizes available to the JVM-Xmx
: the maximum memory sizes available to the JVM-Xms
,-Xmx
:用于JVM heap。Increasing the amount of memory available can improve performance, but increasing it to too high a value can have a detrimental effect in the form of longer pauses for full garbage collection runs. Therefore, the initial and maximum sizes should be set to the same value.-Xmn
:新生代大小-XX:SurvivorRatio
:Eden与Survivor区的比例-XX:PretenureSizeThreshold
:晋升老年代对象的年龄-Xss
:栈容量-Xoss
:本地方法栈大小(因为HotSpot不区分虚拟机栈和本地方法栈,所以该参数无效)-XX:MaxDirectMemorySize
:本地直接内存的最大容量。默认与java堆最大值一样-Xnoclassgc
:是否对类进行回收(HotSpot VM)-verbose:class
,-XX:+TraceClassLoading
,-XX:+TraceClassUnLoading
:查看类加载卸载信息。第三个需要FastDebug版虚拟机支持-XX:+/-UseTLAB
:是否启用本地线程分配缓存Xlog:gc*
,-XX:+PrintGCDetails
:打印GC日志-XX:MaxTenuringThreshold
:晋升老年代的年龄阀值-XX:+PrintReferenceGC
:GC时打印finalReference的信息-XX:+PrintGCApplicationStoppedTime
:打印到达安全点的信息-XX:UseCompressedOops
:开启压缩指针-XX:PretenureSizeThreshold
:大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(因为新生代采用复制算法收集内存)PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效, Parallel Scavenge收集器不认识这个参数, Parallel Scavenge收集器一般并不需要设置。 如果遇到必须使用此参数的场合,可以考虑ParNew加CMS的收集器组合。
实验和调优
本地方法栈
- HotSpot,单线程下怎样测试都只抛出
StackOverflowError
,所以StackOverflowError
应该不是因为调用深度超过某个值就抛出,而是内存不足导致抛出(所以相应减小栈上变量的总大小,可以提高栈深度) - 如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下, 就只能通过减少最大堆和减少栈容量来换取更多的线程
GC
- 大对象对GC不友好,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。写程序时应当避免“短命大对象”。
局部变量表的slot复用与GC
代码1
1
2
3
4
5
6
7class Test {
public static void main(String[] args) {
byte[] placeHolder = new byte[1024 * 1024 * 64];
System.gc();
// gc后,placeHolder没有被gc掉
}
}代码2
1
2
3
4
5
6
7
8
9class Test2 {
public static void main(String[] args) {
{
byte[] placeHolder = new byte[1024 * 1024 * 64];
}
System.gc();
// gc后,placeHolder没有被gc掉
}
}代码3
1
2
3
4
5
6
7
8
9
10class Test3 {
public static void main(String[] args) {
{
byte[] placeHolder = new byte[1024 * 1024 * 64];
}
int a = 0;
System.gc();
// gc后,placeHolder被gc掉
}
}如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不会再使用的变量,手动将其设置为null值(用来代替那句
int a=0
,把变量对应的局部变量表Slot清空)便不见得是一个绝对无意义的操作,这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到JIT的编译条件)下的“奇技”来使用。从编码角度讲, 以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法, 如代码清单8-3那样的场景并不多见。 更关键的是, 从执行角度讲, 使用赋null值的操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的,但是实际执行中,与概念模型差别很大,所以赋null可能无必要(然而我自己使用java11,上面那个测试代码,G1,似乎没有
int a=0
就没有回收)