JVM学习笔记
JVM内存结构
根据《Java虚拟机规范(Java SE 7版)》规定,Java虚拟机内存结构可划分为以下区域:
程序计数器:
- 程序计数器是一块较小的内存空间,可看作是当前线程所执行的字节码的行号指示器。在虚拟机概念模型里,字节码解释器工作时就是通过改变该计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖该计数器来完成。
- JVM中多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的。即在任何时刻,CPU只会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,该内存为线程私有。
- 如果线程正在执行一个Java方法,则PC记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是Native方法,则PC值为Undefined,该内存区域是唯一一个没用OOM的区域。
虚拟机栈:
- Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的时候会创建一个栈帧,用来存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用至执行完成的过程对应着一个栈帧在虚拟机栈中入栈到出栈的过程.
- 局部变量表:存放编译期可知的各种基本数据类型(如Boolean、byte、char、short、int、float、long、double)、对象引用类型(如:引用指针、句柄等)。局部变量表所需内存空间在编译期间完成分配,即进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是确定的,在方法运行期间不会改变局部变量表的大小。
- 异常情况:
- StackOverflowError异常:线程请求的栈深度大于虚拟机所允许的深度时,会抛出栈上溢异常
- OutOfMemoryError异常:虚拟机栈动态扩展时无法申请到足够的内存,会抛出内存溢出异常
本地方法栈:
- 发挥的作用与虚拟机栈类似,区别在于虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务,如Java访问C语言的方法、汇编程序等。
- 异常情况:与虚拟机栈一样。
堆:
- 堆是Java所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建,用于存放对象实例,也是垃圾收集器管理的主要区域。
- 根据GC分代收集算法,堆可细分为:新生代和老年代;新生代又分为Eden区、Survivor区(from,to)从内存分配的角度看,线程共享的Java堆可划分出多个线程私有的分配缓冲区(TLAB:Thread Local Allocation Buffer)
- 堆内存仅要逻辑上连续即可,物理上不连续也可以,如果在堆中没有内存完成实例分配。并且堆也无法再扩展时,则会抛出OOM异常
方法区:
- 与堆一样,方法区是各线程共享的,用于存储已被虚拟机 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
- 对于开发者来说,该区又称为“永久代”Permanent Generation,当方法区无法满足内存分配时,将抛出OOM异常
堆内存结构
堆内存分配策略:
- new一个对象时,大对象(如长字符串与大数组)直接存放在老年代,其他普通对象存放在新生代的eden区
- eden区中的对象,在经历第一次GC后,如果没有被回收,虚拟机则把存活的对象存放在survivor中的from区
- eden区中的对象,在经历第二次GC后,如果没有被回收,虚拟机则把存活的对象存放在survivor中的to区,同时把存活在from区的对象从from区复制到to区,from区与to区指向交换
- 以此类推,继续进行GC,存活对象存放在survivor区,from与to角色不断互换。
- 经历了多次GC后,如果survivor区中对象仍然存活(达到GC年龄),则会晋升到老年代
Java对象内存分配与逃逸分析
1、Java对象的分配:
- 栈上分配
- 线程私有小对象
- 无逃逸
- 支持标量替换
- 无需调整(虚拟机自动优化,无需调优)
- 线程本地分配TLAB(Thread Local Allocation Buffer)
- 占用eden,默认1%,仍在堆上申请,用作线程专用
- 多线程的时候不用竞争(加锁)eden就可以申请空间(同步消除),提高效率
- 小对象
- 无需调整
- 老年代
- 大对象(大数组、长字符串)
- eden
- new普通对象
分配策略: 如果JVM启动了逃逸分析,那么new一个对象时,首先会尝试在栈上分配,如果分配不了,则会尝试在线程本地分配,如果栈上分配与线程本地分配均分配失败的话,则会先判断该对象是否为大对象,如果是大对象,则在老年代分配内存,否则到新生代的eden区分配。
2、逃逸分析
逃逸分析是一种为其他优化手段提供依据的分析技术,其基本行为是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸;也有可能被其外部线程访问到,如复制给类变量或者可以在其他线程中访问的实例变量,称为线程逃逸。 如果一个对象不会逃逸到方法或者线程之外,则可以对这个对象进行一些高效的优化:
栈上分配Stack Allocation:如果一个对象不会逃逸到方法之外,那么可以让这个对象在栈上分配内存,以提高执行效率,对象所占内存会随着栈帧出栈而销毁。在一般应用中,无逃逸的局部变量对象所占的比例较大,如果能使用栈上分配,那么大量的对象就会随着方法的结束而自动销毁,GC压力减小很多。
同步消除SynchronizationElimination:线程同步是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么该变量的读写不存在竞争关系,即可以消除掉对这个变量的同步措施
标量替换:
标量:指的是一个数据已经无法再分解成更小的数据来表示了,Java虚拟机的原始数据类型(int,float等数值类型以及reference类型)都不能再进行进一步的分解
聚合量:相对于标量,如果一个数据可继续分解,则可以称作聚合量,Java对象是典型的聚合量。
如果把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问,这过程成为标量替换
如果逃逸分析可以确定一个对象不会被外部访问,且这个对象可以被拆散,那程序真正执行的时候,可以不创建这个对象,而是直接创建它的成员变量来替换这个对象。将对象拆分后,可以在栈上分配内存
3、测试实例
1 | /** |
结果分析:
a. 无逃逸分析、无栈上分配、不使用线程本地内存:
1 | -XX:-DoEscapeAnalysis -XX:-EliminateAllocations -XX:-UseTLAB -XX:+PrintGC |
控制台输出:
1 | [GC (Allocation Failure) 49152K->688K(188416K), 0.0010012 secs] |
b. 使用线程本地内存,无需在eden区分配内存时加锁,效率变高
1 | -XX:-DoEscapeAnalysis -XX:-EliminateAllocations -XX:+UseTLAB -XX:+PrintGC |
控制台输出:
1 | [GC (Allocation Failure) 49760K->640K(188416K), 0.0007129 secs] |
c. 开启逃逸分析、使用标量替换、使用线程本地内存、效率变高
1 | -XX:+DoEscapeAnalysis -XX:+EliminateAllocations -XX:+UseTLAB -XX:+PrintGC |
控制台输出:
1 | [GC (Allocation Failure) 49152K->688K(188416K), 0.0010576 secs] |
问题分析:开启逃逸分析存在开销,有时效率不如未开逃逸分析时的效率高
垃圾回收算法
1、什么是可回收对象(垃圾)?
- 强引用:强引用指的是子程序代码中普通存在的,类似Object obj = new Object()这类的引用,只要强引用还在,垃圾收集器则不会回收掉被引用的对象.
- 软引用:软引用用于描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常,在jdk1.2之后,提供了SoftReference类来实现软引用.
- 弱引用:弱引用也是用于描述非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象,在jdk1.2之后,提供了WeakReference类来实现弱引用.
- 虚引用:也称为幽灵引用、幻影引用,是最弱的一种引用关系,一个对象是否有虚引用的存在,不会对其生成时间构成影响,无法通过虚引用来引用对象。为一个对象设置虚引用的目的是能在这个对象被收集器回收时收到一个系统通知,jdk1.2提供PhantomReference类来实现虚引用.
2、GC是如何确定垃圾的?
引用计数法
- 算法思路:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1;任何时刻计数器为0的对象则是不可用的。
- 引用计数算法(Reference Counting)的实现简单,判定效率也很高,但是无法解决循环引用问题
可达性分析
从roots对象计算可以达到的对象
可作为GC Roots的对象包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
算法思路:以GC Roots 对象为起始点,从这些节点开始向下搜索(深度搜索),搜索所走过的路劲成为引用链(Reference Chain),当一个对象到GCRoots不存在引用链(不可达)时,则证明此对象是不可用,即判定为可回收对象。逻辑图如下:
3、GC算法
- Mark-Sweep标记清除
- 算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,标记过程通过可达性分析,将不可达的对象进行标记判定。
- 不足之处:
- 效率问题,标记和清除两个过程效率都不高
- 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致后续需要分配较大内存的对象时,无法找到足够的连续内存,而不得不提前触发一次FGC
- Copying复制
- 将内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把使用过的内存空间一次清理掉。常用于新生代 survivor区的from/to的复制
- 优点:在内存上进行复制效率高,不存在内存碎片化问题
- 缺点:内存空间利用率低,算法代价高,因此实际分给新生代中的survivor区内存较小,与Eden区比例约为8:1:1
- Mark-Compact标记压缩:
- 标记过程仍然与标记-清除算法一样,采用可达性分析标记判定,然后让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
- 该算法效率略低于复制算法,但内存空间利用率高,常用于老年代GC
分代收集算法:
当前商业虚拟机的垃圾收集都采用分代收集(GenerationalCollection)算法,,该算法根据对象存活周期的不同将堆内存划分为几块:新生代、老年代,然后根据各个年代的特点采用最适当的GC算法:
- 在新生代中,每次GC时都发现有大量对象死去,只有少量存活,则选用复制算法,只需要付出少量存活对象的复制成本即可完成GC
- 在老年代中,对象存活率高、,没有额外空间对它进行分配担保,则需要使用标记-清除算法或者标记-压缩算法(默认使用)进行GC
4、垃圾收集器
串行收集器 Serial Collector:
串行收集器使用单个线程执行所有垃圾收集工作,这使得它相对高效,因为线程之间没有通信开销。它最适合于单处理器机器,因为它不能利用多处理器硬件,尽管对于具有小数据集(高达大约100 MB)的应用程序,它可能对多处理器很有用。串行收集器在某些硬件和操作系统配置中默认选中,或者可以使用该选项明确启用-XX:+UseSerialGC。
XX:+UseSerialGC
单线程
并行收集器 Paraller Collector:
官方文档(翻译):并行收集器(也称为吞吐量收集器)并行执行次要收集,这可以显着减少垃圾收集开销。它适用于在多处理器或多线程硬件上运行的中型到大型数据集的应用程序。并行收集器在某些硬件和操作系统配置上默认选中,或者可以使用该选项明确启用-XX:+UseParallelGC。
并行压缩是一个使并行采集器能够并行执行主要采集的功能。如果没有并行压缩,主要集合将使用单个线程执行,这可能会极大地限制可伸缩性。如果-XX:+UseParallelGC指定了选项,则默认启用并行压缩。关闭它的选项是-XX:-UseParallelOldGC。
并发量大,每次GC时,JVM需要停顿
并发收集器
CMS Collector: 此收集器适用于希望缩短垃圾收集暂停时间并能够与垃圾收集共享处理器资源的应用程序。
- 停顿时间短
G1: 这种服务器式垃圾收集器适用于内存较大的多处理器机器。它以高概率满足垃圾收集暂停时间目标,同时实现高吞吐量。
- 停顿短,同时并发大
并发开销:
大多数并发收集器交换处理器资源(否则可用于应用程序)以缩短主要收集暂停时间。最明显的开销是在收集的并发部分期间使用一个或多个处理器。在N处理器系统上,并发部分集合将使用可用处理器的K / N,其中1 <= K <= ceiling { N / 4}。(注意K上的精确选择和边界)除了在并行阶段使用处理器之外,还会产生额外的开销以实现并发。因此,虽然垃圾收集暂停通常比并发收集器短得多,但应用程序吞吐量也往往略低于其他收集器。
在具有多个处理核心的计算机上,处理器可用于集合并发部分中的应用程序线程,因此并发垃圾收集器线程不会“暂停”应用程序。这通常会导致更短的暂停,但是应用程序可用的处理器资源也较少,应该会出现一些减速,特别是在应用程序最大限度地使用所有处理内核的情况下。随着N的增加,由于并发垃圾收集导致的处理器资源减少变得更小,同时收集的收益也增加。的部分并行模故障在并发标记扫描(CMS)集电极讨论了这样的缩放潜在限制。
由于至少有一个处理器用于并发阶段的垃圾收集,因此并发收集器通常不会为单处理器(单核)机器提供任何好处。但是,对于CMS(不是G1),可以使用单独的模式,可以在只有一个或两个处理器的系统上实现低暂停; 看到增量模式在并发标记扫描(CMS)收集器的详细信息。此功能在Java SE 8中不推荐使用,并可能在以后的主要版本中删除。
选择收集器:除非应用程序具有相当严格的暂停时间要求,否则请先运行您的应用程序并允许VM选择收集器。如有必要,请调整堆大小以提高性能。如果性能仍不能达到您的目标,请使用以下指南作为选择收集器的起点。
- 如果应用程序有一个小数据集(最多大约100MB),那么用选项选择串行收集器-XX:+UseSerialGC。
- 如果应用程序将在单个处理器上运行,并且没有暂停时间要求,则让VM选择收集器,或者使用该选项选择串行收集器-XX:+UseSerialGC。
- 如果(a)峰值应用程序性能是第一优先级并且(b)没有暂停时间要求或暂停1秒或更长时间是可接受的,则让VM选择收集器,或者选择并行收集器-XX:+UseParallelGC。
- 如果响应时间比整体吞吐量更重要,并且垃圾收集暂停时间必须短于大约1秒,那么使用-XX:+UseConcMarkSweepGC或选择并发收集器-XX:+UseG1GC。
- 如果推荐的收集器无法达到所需的性能,请首先尝试调整堆和代的大小以达到所需的目标。如果性能仍然不足,请尝试使用其他收集器:使用并发收集器来减少暂停时间,并使用并行收集器来提高多处理器硬件的整体吞吐量。
OOM测试实例与内存查看工具的使用
1、OOM测试实例
1 | /** |
分析:
- 一般情况下,虚拟机的初始堆内存会比最大堆内存要小,而调优时往往会把初始值-Xms调至最大值-Xmx或者接近最大值,目的是减少中间的GC内存计算过程。
- 例如,设置-Xmx1G,-Xms256M,当程序运行时,虚拟机会不断地进行GC、申请新内存用于存新对象,且进行一次GC的效率较低,耗时。而直接设置-Xms1G时,初始内存开始就分配1G,与最大内存相等,程序运行时就省去了中间的内存计算及GC过程,进而提高了效率,这是调优的小技巧。
2、StackOverflow栈溢出
1 | public class JVMTest4 { |
结果:
1 | java.lang.StackOverflowError |
- 分析:
- ,在JVM调优时,-Xss也是一个非常重要的调优参数,当-Xss调的值较小时,线程的并发数就多(总内存不变,每个线程分的内存少,线程数自然变多)
- 而当-Xss调的比较大,则线程递归深度就深(内存分得多,调用栈深度越深,同时线程数变少),该值属于经验值,需要结合业务来进行分析。
JVM参数
1、商业虚拟机:
- HotSpot:oracle商业虚拟机,在jdk1.8下,默认模式是Server
- Openjdk:开源虚拟机
2、JVM参数格式
-:标准参数,所有JVM都应该支持,可在命令行下输入Java查看 -X : 非标准参数,每个JVM实现都不同 -XX : 不稳定参数,下一个版本可能会取消
3、常用JVM参数
- 堆设置:
- -Xms 初始堆大小
- -Xmx 最大堆大小
- -Xss 线程栈大小
- -XX:NewSize=n 设置新生代大小
- -XX:NewRatio=n设置新生代和老年代的比值,如-XX:NewRatio=3,表示新生代:老年代= 1:3,新生代占整个新老年代和的1/4
- -XX:SurvivorRatio=n新生代中eden区与两个survivor区的比值,如-XX:SurvivorRatio=3,表示eden:survior =3:2,一个survivor区占整个新生代的1/5
- -XX:MaxPermSize=n 设置永久代大小
- 收集器设置:
- -XX:+UseSerialGC 设置使用串行收集器
- -XX:+UseParallelGC 设置并行收集器
- -XX:+UseConcMarkSweepGC 设置并发收集器
- GC统计信息:
- -XX:+PrintGC 打印GC信息
- -XX:+PrintGCDetails 打印详细GC信息
- -Xloggc:filename 打印GC信息到日志文件中
- 其他:
- -XX:-DoEscapeAnalysis 关闭逃逸分析
- -XX:-EliminateAllocations 关闭标量替换
- -XX:-UseTLAB 关闭线程本地内存
tomcat参数配置
1 | set JAVA_OPTS = |
-Xms的内存值如何选择:
根据实际业务来定,先查看服务器上部署了多少个Java应用,再来选择
例如:服务器内存64g,只部署了一个tomcat应用,那么可以设置-Xms的值接近64g,以达到内存最大利用,但是要注意设置前提:仅部署一个tomcat,如果部署了多个应用,则要根据实际业务来权衡
例如,一些业务中的实现需要频繁new对象的,则可以分配较大的eden区内存(调整-XX:NewRatio=n ,新老年代内存比例),以满足业务需求
而另一些业务服务需要不断的运行,老年代上对象占用较多,则可以分配较大的old区内存
-XX:PermSize -XX:MaxPermSize值如何选择:当程序中类信息比较多的时候(类信息存在永久代),可适当调大永久代的内存空间,如:Eclipse启动速度慢,可以调大永久代内存大小,使得启动速度变快