《深入理解Java虚拟机》阅读笔记 第二部分 Java自动内存管理
前言:JVM的自动内存管理机制,从理论知识、异常现象、代码、工具、案例和实战多角度讲解。
一、Java内存区域与内存异常
1. 运行时数据区域
Java虚拟机在执行Java程序过程中会把它管理的内存划分为若干不同的数据区域。
图源网络,侵删
程序计数器
可以看做是当前线程所执行的字节码的行号指示器,计数器的值表明下一条需要执行的字节码指令。
因为所有线程都是轮流使用处理器,计数器的存在可以在线程得到处理器时间时能够恢复到正确的执行位置,每个线程都有自己独立的程序计数器,属于线程私有内存。
(程序计数器是唯一一个没有OOM的区域)
Java虚拟机栈
也是线程私有,虚拟机栈描述的是Java方法执行的内存模型,每个方法被执行的时候都会创建一个栈帧(栈帧是方法运行期的基础数据结构),用来存储局部变量表等信息,每个方法被调用直至执行完成对应着虚拟机栈中从入栈到出栈的过程。
局部变量表存放各种基本数据类型、对象引用和字节码指令地址。需要的空间在编译期间完成分配,大小已经确定不会在运行期间改变。
虚拟机栈的异常分两种:StackOverflowError是栈深度大于JVM允许的最大深度,某个线程的栈内存超过了JVM的限制;OutOfMemoryError是无法向操作系统申请更多的物理内存给JVM栈使用。
本地方法栈
与虚拟机栈类似,区别在于虚拟机栈为虚拟机执行Java方法,本地方法栈为虚拟机使用到的Native方法服务。
有点虚拟机会把本地方法栈和虚拟机栈合二为一。
Java堆
Java堆是最大的一块,在虚拟机启动时创建,是被所有线程共享的一块内存区域,此内存区域的唯一目的就是存放对象示例。所有的对象示例以及数组都要在堆上分配。
Java堆内存可以分为新生代和老年代,再细致一点有Eden空间,From Survivor空间,To Survivor空间。
Java堆可以处于物理上不连续的内存空间中,只需要逻辑上是连续的即可。
方法区
方法区也是线程共享的内存区域,用于存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
方法区的回收主要是对常量池的回收和对类型的卸载,这部分区域很容易造成未完全回收导致内存泄漏。
运行时常量池
运行时常量池是方法区的一部分,常量池主要存放一些编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
除了会保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
直接内存
并不是虚拟内存的一部分,不属于JVM规范定义的内存区域。
2. 对象访问
不同的虚拟机实现的对象访问方式会有所不同,主流的方式有两种:使用句柄和直接指针。
- 句柄方式
Java堆中将会划出一块内存来作为句柄池,reference存储的就是对象的句柄地址,句柄中包含了对象实例数据和类型数据各自的具体地址信息。
- 直接指针
直接指针的话Java堆对象布局中就必须考虑如何放置访问类型数据的相关信息,reference存的就是对象的地址。
句柄方式在对象移动时不需要修改reference,只需要修改句柄中的示例数据指针;直接指针访问速度快。
3. 实战OutOfMemoryError
除了程序计数器外,其他虚拟机内存区域都有可能发生OOM。
Java堆溢出
OOM产生原因:不断的创建对象,并且保证GC Roots到对象之间有可达路径。设置参数:堆最小值-Xms、堆最大值-Xmx
如果是内存泄漏,查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的路径与GC Roots相关联并导致无法自动回收的。如果是内存溢出,就应该检查虚拟机的堆参数,看看是否可以继续调大,或从代码检查是否存在某些对象生命周期过长、持有状态时间过长的情况。
虚拟机栈和本地方法栈的溢出
栈容量由-Xss参数设定,虚拟机栈和本地方法栈有如下两种异常:
- 线程请求的栈深度大于虚拟机所允许的最大深度,StackOverflowError
- 虚拟机在扩展栈时无法申请到足够的内存空间,抛出OutOfMemoryError
如果是建立多线程导致内存溢出,在不能减少内存数和更换64位虚拟机的情况下,只能通过减少最大堆和减少栈容量来换取更多的线程。
运行时常量池溢出
常量池在方法区中,可以通过-XX:PermSize和-XX:MaxPermSize限制方法区的大小,从而间接限制常量池的容量。不断新建有引用的常量,可以导致OOM,提示信息为:PermGen space,也说明运行时常量池属于方法区。
方法区溢出
方法区存放Class相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。当运行时产生大量的类填满方法区时会产生溢出的情况。
实际应用中,如果在Spring或者Hibernate中经常对类增强时,会用到动态生成的Class保证可以加载到内存。在经常动态生成大量Class的应用中需要多加注意。
本机直接内存溢出
直接内存通过-XX:MaxDirectMemorySize指定,如果不指定就和Java堆最大值(-Xmx)一样。直接内存异常时并没有真正去申请分配内存,而是通过计算的值没有内存分配手动抛出异常。
二、垃圾回收器与内存分配策略
1. 对象是否存活
堆中几乎放着所有Java对象的实例,垃圾收集器对堆进行回收时首先要确定哪些对象还在被使用着。
引用计数算法
给对象加一个引用计数器,每当一个地方引用时计数器值+1,引用失效时计数器值-1,为零时表示不可能再被使用。
引用计数算法不能解决对象之间相互循环引用的问题。
根搜索算法
通过一系列GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,表明此对象是不可用的。
GC Roots的对象包括以下几种:①虚拟机栈的栈帧中的本地变量表 ②方法区的类静态属性 ③方法区中的常量引用对象 ④本地方法栈中Native方法引用的对象
引用的类型
- 强引用
最普遍存在,类似“Object obj = new Object()”这类,强引用存在就不会被回收掉 - 软引用
还有用、但非必需的对象,在系统发生内存溢出之前,将这些对象进行回收,还是没有足够的内存才会抛出内存溢出异常。 - 弱引用
非必需对象,强度比软引用更弱,当垃圾回收工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。 - 虚引用
最弱的一种引用关系,一个对象的虚引用不会对起生存时间构成影响,也无法通过虚引用来取得一个对象实例,唯一作用是希望这个对象被回收时收到一个系统通知。
finalize()自我救活
GC Roots搜索中的不可达对象要经历两次标记过程才会被回收。第一次标记时会进行一次筛选,查看对象是否有必要执行的finalize()方法,如果有要执行的finalize(),该对象会被放入一个队列中。虚拟机会执行该方法,但不会等待它运行结束。这里的finalize()中是自我救活的最后一次机会,该方法内将自己和引用链上的任何对象重新建立关联即可。
任何一个对象的finalize()方法都只会被系统自动调用一次,调用过以后再面临下一次回收时finalize()方法不会再次被执行。
任何时候都不推荐finalize(),这是Java向C/C++析构函数的妥协,应当使用try-finally实现类似功能。
方法区的回收
满足以下三个条件的类才能算是废弃的常量: ①Java堆中不存在该类的任何实例 ②加载该类的ClassLoader已经被回收 ③类的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类方法。
2. 垃圾收集算法
各大平台的虚拟机操作内存的方法各不相同,本部分只介绍部分算法思想。
标记清除
最基础的收集算法,标记需要回收的对象,标记完成统一回收。该方法存在有有效率低下、回收后产生大量不连续的内存碎片的问题。
复制算法
可用内存划分为大小相同两块,每次使用一块,一块用完时将存活的对象复制到另一块,这样解决了内存碎片化问题。
复制算法示意图
这种算法被广泛应用于新生代的回收中,实际使用中,新生代将内存划分为较大的Eden空间和两个较小的Survivor空间。对象被创建时先放到Eden,执行垃圾回收时Eden中还存活的对象放到ToSurvivor中,FromSurvivor中还存活的对象也拷贝到ToSurvivor上,然后To和From互换身份。这个过程也被称为Minor GC。
标记-整理
和“标记-清除”算法过程大体一样,只是在标记完成后不直接对可回收的对象进行清理,而是让所有存活的对象都向一端移动。
分代收集算法
根据对象的生存周期不同将内存划分为几块,一般是新生代和老年代,根据各个年代的特点采用不同的收集算法。
新生代存活率低,采用复制算法;老年代存活率高,存勇标记-清除或者标记-整理。
3. HotSpot的算法实现
枚举根节点
从GC Roots节点找引用链这个操作比较耗时,要逐个检查方法区中的引用,HotSpot的实现中,把对象内什么偏移量上是什么类型的数据计算出来(一组称为OopMap的数据结构),在JIT编译过程中也会在特定的位置记录下栈和寄存器中哪些位置是引用,使得GC扫描时可以直接获取这些信息
安全点
HotSpot只在特点的位置记录OopMap信息,这些位置叫做安全点,安全点的选取不能太少增大GC等待时间,也不能太多增大运行负荷;还有一个问题是如何保证每个线程在GC发生时都能“跑”到最近的安全点再停顿下来。
4. 垃圾收集器
垃圾收集器可以理解为垃圾回收算法的具体实现。
这是七种作用于不同分代的收集器,上半部分是年轻代,下半部分是老年代部分,如果两个收集器之间存在连线,那也就是说明它们可以搭配使用。
没有任何一个收集器能放之四海皆准,只有对具体应用最合适的收集器。
Serial收集器
原来是新生代的唯一选择,单线程收集器,这里的单线程意思是进行垃圾回收时会停止掉其他所有的工作线程。
Serial收集器的优点在于简单而高效,专心做垃圾回收,停顿时间一般不会超过一百多毫秒。
ParNew收集器
可以理解为Serial收集器的多线程版本,除了使用多条线程进行垃圾回收外其余均和Serial收集器一致,实际上共用了很多代码。
ParNew收集器在单CPU环境中绝对不会比Serial收集器效果更好。
除了Serial之外,ParNew是新生代中唯一可以和CMS搭配使用的收集器。
Parallel Scavenge收集器
新生代收集器,使用复制算法,并行的多线程收集器。Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。高吞吐量也就是高效率地利用CPU时间,尽快完成程序的运行任务。
Serial Old收集器
Serial收集器老年代版本,单线程收集器,使用“标记-整理”算法。
Parallel Old收集器
Parallel Scavenge收集器的老年代版本,使用多线程的“标记-整理”算法。
CMS收集器
目标是获取最短回收停顿时间,也可以说是重视服务的响应速度,基于“标记-清除”算法实现。
运作过程相对更加复杂,过程分为4个步骤:初始标记、并发标记、重新标记和并发清除。初始标记和重新标记会停掉其他所有工作线程,初始是标记一下GC Roots直接关联的对象,重新标记修正并发标记期间标记产生变动的部分。
优点在于:并发收集、低停顿。
缺点在于:对CPU资源敏感,不同数量的CPU上的利用率差异大;无法处理浮动垃圾;基于“标记-清除”会产生大量空间碎片。
G1收集器
当前最前沿成果,对比CMS的改进在于:基于“标记-整理”;非常精准的控制停顿。
G1是Garbage First的简称,因为能避免全区域的垃圾收集,将Java堆氛围多个大小固定的独立区域(包括新生、老年代),维护一个优先列表,优先回收垃圾最多的区域。
5. 内存分配与回收策略
对象优先在Eden分配
大多数情况下,对象分配在新生代Eden区上,当没有足够的内存时,会发起一次新生代的Minor GC。
对象直接进老年代
大对象就是指需要大量连续内存空间的Java对象,要避免代码中存在很多“朝生夕灭”的“短命大对象”。
长期存活的对象进入老年代
JVM给每个对象定义了一个对象年龄计数器,对象在一次次Minor GC后每存活一次对象年龄加一,年龄到一定程度(默认15岁)就会被晋升到老年代中。
动态对象年龄判定
并不一定是到达设置的年龄后才能进入老年代,当Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半是,年龄大于改年龄的对象就直接进入老年代。
空间分配担保
Minor GC时,虚拟机会检查晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于进行一次Full GC。
当出现大量对象在Minor GC后仍然存活的情况时,需要老年代进行担保,让Survivor无法容纳的对象直接进入老年代。
三、 虚拟机性能监控与故障处理
1. JDK的命令行工具
jps(JVM Process Status Tool),虚拟机进程状况工具,可以列出正在运行的虚拟机进程,显示虚拟机执行主类。
jstat(JVM Statistic Monitoring Tool),虚拟机统计信息监视工具,监事虚拟机各种运行状态信息,运行期间定位虚拟机性能。
jinfo(Configuration Info for Java),Java配置信息工具,实时查看和调整虚拟机的各项参数。
jmap(Memory Map for Java),Java内存映像工具,用于生成对转出快照。jmap的作用还可以查询finalize执行队列,Java堆和永久代的详细信息。
jhat(JVM Heap Analysis Tool),虚拟机堆转储亏按照分析工具,与jmap搭配使用,来分析生成的堆转储快照。
jstack(Stack Trace for Java),Java堆栈跟踪工具,用于生成虚拟机当前时刻的线程快照,也就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成快照的目的是定位线程出现长时间停顿的原因。
2. JDK的可视化工具
JConsole,一个基于JMX的可视化监视和管理工具。
VisualVM,强大的运行监视和故障处理程序。
四、 调优案例分析与实战
1. 常见情况分析
- 高性能硬件上的程序部署策略
两种主流方式: ①通过64位JDK来使用大内存 ②适用若干32位虚拟机建立逻辑集群。
方案①的缺点:内存回收耗时长、性能低于方案②、程序需要足够稳定、消耗的内存更大。
因此更多人选择方案②搭配一个负载均衡器。但方案②也有如下缺点:磁盘竞争导致IO异常、很难高效的利用某些资源池、32位内存限制、大量使用缓存的应用会造成每个节点上的内存浪费。
- 集群建同步导致的内存溢出
当网络情况不能满足集群间频繁的数据传输要求时,重发数据在内存中不断的堆积,很容易出现内存溢出。
- 堆外内存导致的溢出错误
除了Java堆和永久代内存外有:Direct Memory(直接内存),线程堆栈,Socket缓存区,JNI代码,虚拟机和GC。
- 服务器JVM进程崩溃
客户端与服务器调用采用异步方式,速度上的不对等是的web服务堆积越来越多,超过承受能力产生崩溃。
2. 常见运行速度调优
通过参数的设置可以实现以下优化:
- 编译时间和类加载时间优化
- 调整内存设置垃圾回收频率
- 选择收集器降低延迟
Ref
周志明. 深入理解Java虚拟机[M]. 机械工业出版社, 2013.