核心概述
一个JVM只有一个堆内存,堆也是Java内存管理的核心区域。
Java堆区在JVM启动时被创建,其空间也就确定了,是JVM管理的最大一块内存空间。
堆可以处于物理内存上不连续的内存空间中,但在逻辑上它应该被视为连续的。
所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区。
所有的对象实例以及数组都应该在运行时分配在堆上。
数组和对象可能永远不会存储在栈上,因为栈帧保存引用,这个引用指向对象或数组在堆中的位置。
方法结束,堆中的对象不会立即被移除,仅仅在垃圾收集时才会被移除。
堆内存细分
Java 7及之前版本堆内存逻辑上分为三个部分,新生代+老年代+永久代。
- Young Generation Space - 新生代,又被划分为Eden和两个Survivor区,空的Survivor是to区,另一个是from区。
- Tenure Generation Space - 老年代,在两个Survivor区回收多次后仍然无法回收的对象会被移到老年代,可能是15次,这个数字不确定。
- Permanent Space - 永久代。
Java 8及之后版本堆内存逻辑上分为三个部分,新生代+老年代+元空间。
- Young Generation Space - 新生代,又被划分为Eden和两个Survivor区,空的Survivor是to区,另一个是from区。
- Tenure Generation Space - 老年代,在两个Survivor区回收多次后仍然无法回收的对象会被移到老年代,可能是15次,这个数字不确定。
- Meta Space - 元空间。
常用工具
- JDK命令:jmap、jinfo、javap等
- Eclipse:Memory Analyzer Tool
- jconsole
- visualvm
- jprofiler
- Java Flight Recorder
- GCViewer
- GC Easy
堆内存大小
堆分代思想
不同对象的生命周期不同,大多是临时对象。
分代可以优化GC性能,如果没有分代,那所有的对象都在一起,GC的时候要找到没有使用的对象,需要对整个堆空间进行扫描。如果分代的话,把新创建的对象放到一起,把生命周期长或者与JVM进程同生同死的对象放到一起,可以提供GC的性能。
堆内存大小设置
Java堆用于存储Java对象实例,在JVM进程启动时就已经设定了,可以使用-Xmx和-Xms设置内存大小。
- -Xms设置堆起始内存
- -Xmx设置堆最大内存
一旦堆区的内存大小超过了-Xmx设置的值,就会抛出OutOfMemoryError错误。
通常将两个参数设置为一样,为了在Java垃圾回收清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
默认-Xms=物理内存/64,-Xmx=物理内存/4。
堆内存大小查看
查看内存和GC信息:
jps
jstat -gc pid
或者使用java的vm option参数:
-XX:PrintGCDetails
- 新生代大小 = Eden区 + 1个Survivor区
- 堆大小 = 新生代 + 老年代
OutOfMemoryError示例
各个区的比例设置
设置新生代和老年代比例:
-XX:NewRatio=2
表示老年代与新生代的比值是2:1
设置Eden与Survivor比例:
-XX:SurvivorRation=8
表示Eden与Survivor区的比值是8:1:1
直接设置新生代内存:
-Xmn
自适应内存大小参数:
-XX:-UseAdaptiveSizePolicy - 取消自适应内存大小
-XX:+UseAdaptiveSizePolicy - 开启自适应内存大小
不好用
命令查看进程信息:
jinfo -flag SurvivorRatio pid
jinfo -flag NewRatio pid
堆空间参数
-XX:+PrintFlagsInitial查看所有参数的默认初始值
-XX:+PrintFlagsFinal查看所有参数的最终值
-Xms初始堆内存
-Xmx最大堆内存
-Xmn设置新生代内存
-XX:NewRatio设置新生代与老年代的比值
-XX:SurvivorRatio设置新生代Eden与s区的比值
-XX:MaxTenuringThreshold设置新生代垃圾的最大年龄
-XX:+PrintGCDetails输出详细的GC日志
-XX:+PrintGC
-verbose:gc
-XX:HandlePromotionFailure设置空间分配担保
-XX:+DoEscapeAnalysis开启逃逸分析
-XX:+PrintEscapeAnalysis查看逃逸分析筛选结果
-XX:+EliminateAllocations开启标量替换
对象分配过程
- 对象先放入伊甸区
- 当伊甸区空间满了,新创建的对象时,JVM垃圾回收器将对伊甸区进行垃圾回收(Minor GC),将伊甸区不再被引用的对象进行销毁,然后把新创建的对象放入伊甸区
- 然后将伊甸区的剩余对象移动到s0区
- 如果再次触发垃圾回收,此时s0区中上次幸存的对象会再次尝试回收,幸存下来的又会被移动到s1区
- 幸存对象在s0和s1区之间来回移动,空的那个又被称为to区,被使用的又被称为from区,因此在计算堆总空间时只加了一个Survivor区的内存大小
- 如果经过15次,对象还是没有被回收,这个对象会被移到到老年代,这个次数可以使用-XX:MaxTenuringThreshold=N来设置
- 当老年代内存不足时,会触发Major GC回收,对老年代内存做清理
- 如果老年代垃圾回收之后依然无法容纳新的对象,就会出现OOM错误,即java.lang.OutOfMemoryError:Java heap space错误
小结:s0和s1区总是有一个是空的,就是to区;新生代频繁垃圾回收,老年代很少回收,永久代和元空间几乎不会回收。
GC基础
三种GC
JVM在进行GC时,并非每次都是针对整个堆空间回收,大部分的回收都是指新生代。
针对Hotspot VM实现,里面的GC按照回收区域又分为两大类型,一种是部分收集,一种是整个堆收集。
部分收集:不是完整收集整个Java堆的垃圾,又分为:
- 新生代收集,Minor GC/Young GC,只针对新生代垃圾回收
- 老年代收集,Major GC/Old GC,只针对老年代垃圾回收。目前,只有CMS GC会有单独收集老年代的行为。很多时候Major GC和Full GC混淆使用,需要具体分辨是老年代收集还是整堆收集
- 混合收集,Mixed GC,收集整个新生代以及老年代的垃圾回收。目前,只有G1 GC会有这种行为
整堆收集:即Full GC,收集整个Java堆和方法区的垃圾。
年轻代GC(Minor GC/Young GC)触发机制
当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden区,Survivor满不会触发GC,每一次的Minor GC会清理年轻代的内存。
因为Java对象大多具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度比较快。
Minor GC会引起STW,暂停其他用户线程,等待垃圾回收结束,用户线程才能恢复运行。
老年代GC(Major GC)触发机制
指发生在老年代的GC,对象从老年代消失时,就可以说Major GC/Full GC发生了。
出现Major GC/Full GC经常会伴随至少一次Minor GC,但不是绝对的。也就是说老年代空间不足时,会先尝试触发Minor GC,如果之后空间还是不足,会触发Major GC。
Major GC速度一般比Minor GC慢10倍以上,STW时间更长。
如果Major GC之后内存还是不足,就会抛OOM错误。
Full GC触发机制
- 调用System.gc()方法,但是不一定执行GC
- 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的对象的平均大小大于老年代的可用内存
- 由Eden区、s0向s1复制对象时,对象大小大于s1可用内存,则把该对象复制到老年代,且老年代的可用空间小于这个对象的大小
Full GC是开发、调优中尽量避免的,这样暂停的时间会短一些。
内存分配策略
对象晋升
如果对象在Eden创建经过第一次Minor GC后仍然活着,并且能被s区容纳,将被移动到s区,并将对象年龄设置为1,。对象在s区每经过一次Minor GC,年龄就+1,当他的年龄到达一个阈值,默认15,就会被晋升到老年代。
使用-XX:MaxTenuringThreshold来设置最大年龄。
针对不同年龄的对象分配原则
- 优先分配Eden
- 大对象直接分配到老年代,应该尽量避免大对象
- 长期存活的对象分配到老年代
- 动态对象年龄判断:如果s区中相同年龄的所有对象大小的总和大于s区空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到最大年龄
- 空间担保:-XX:HandlePromotionFailure
空间担保
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
如果大于,则此次Minor GC是安全的。
如果小于,虚拟机会检查-XX:HandlePromotionFailure的值,确认是否允许担保失败。
- 如果-XX:HandlePromotionFailure=true,会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
- 如果大于,则尝试进行一次Minor GC,但这次GC依然是有风险的。
- 如果不大于,则改为进行Full GC。
- 如果-XX:HandlePromotionFailure=false,则改为进行Full GC。
在JDK6 Update24之后,-XX:HandlePromotionFailure参数不会影响到虚拟机的空间分配担保策略,代码不再使用这个参数。JDK6 Update24之后,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则进行Full GC。
TLAB
Thread Local Allocation Buffer.
堆区是线程共享区域,任何线程都可以访问这些数据。
由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。
为了避免多个线程操作同一地址,需要使用加锁方式,但是这样影响分配内存的速度。
什么是TLAB
从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有的缓存区域,包含在Eden区域中。
多线程同时分配内存时,使用TLAB可以避免一系列的线程安全问题,同时还可以提升内存分配的性能,因此可以将这种内存分配方式称为快速分配策略。
所有openjdk衍生出来的JVM都支持TLAB设计。
再说明
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但是JVM确实是将TLAB作为内存分配的首选。
在程序中,开发人员可以通过-XX:UseTLAB设置是否开启此功能。
默认情况下,TLAB空间内存非常小,仅占整个Eden的1%,当然开发者可以通过-XX:TLABWasteTargetPercent设置TLAB的占比。
一旦对象在TLAB空间分配是被,JVM就会尝试通过使用加锁方式确保数据操作的原子性,从而直接在Eden空间分配内存。
命令
jinfo -flag UseTLAB pid
选项
-XX:UseTLAB设置是否开启TLAB功能
-XX:TLABWasteTargetPercent设置TLAB的占比
堆是分配对象的唯一选择吗
随着JIT的发展和逃逸分析技术的成熟,栈上分配、标量替换技术将会导致对可能不在堆上分配。
如果一个对象经过逃逸分析后发现,它没有逃出所在的方法,就可能优化成在栈上分配内存,这样就无需的堆上分配内存,也无须进行垃圾回收,这是最常见的堆外存储技术。
基于openjdk深度开发的taobaoVm中,创新的GCIH技术实现off-heap,将生命周期较长的Java对象移到堆外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC回收频率和提升GC回收效率的目的。
逃逸分析
将对象分配到栈,需要使用逃逸分析技术。
通过逃逸分析,Java虚拟机编译器能够分析出一个新对象的引用的使用范围,从而决定是否将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域。
当一个对象在方法中被定义,而且只在方法内部使用,则认为没有发生逃逸。
当一个对象在方法中被定义,被外部方法引用,则认为发生逃逸,例如作为参数传递给其他方法。
在JDK 6u23之后,默认开启逃逸分析。
开发过程中能够使用局部变量的,就不要在方法外定义。
逃逸分析 - 代码优化
栈上分配 - 直接在栈上为对象分配内存。
同步省略 - 如果一个对象被发现只能从一个线程访问,那么对这个对象的操作可以不考虑同步。
分离对象或标量替换 - 有的对象可能不需要作为一个连续的内存结构存储也可以被访问到,那么对象的部分或全部可以不存储在内存,而是存储在CPU寄存器中。
栈上分配
直接在栈上为对象分配内存。
示例代码使用的参数:
-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
同步省略
如果一个对象被发现只能从一个线程访问,那么对这个对象的操作可以不考虑同步。
javap查看指令,因为同步省略是在运行期间做的,所以代码里面还是有锁的相关指令的。
标量替换
标量是指一个无法再分解成更小数据的数据。Java的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量,Java的对象就是聚合量,因为这些对象还可以分解成其他聚合量或标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问,那么经过JIT优化,就会把这个对象拆解成若干个他包含的成员变量来替换,这个过程就是标量替换。
可以大大减少堆内存的占用,因为一旦不需要创建对象了,那么就不再需要分配内存了。
标量替换为栈上分配提供了很好的基础。
参数设置:
-XX:+EliminateAllocations
这个参数默认是开启的。
示例代码使用的参数:
-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
只有server模式才开启逃逸分析。
-server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
逃逸分析小结
直到JDK 1.6才有逃逸分析的实现,技术不是很成熟。
根本原因就是无法保证逃逸分析的性能,虽然逃逸分析可以做到栈上分配、标量替换和锁消除,但是逃逸分析本身也是需要消耗性能的,无法保证逃逸分析的性能消耗小于抵消掉的性能消耗,并且可能出现逃逸分析之后并没有栈上分配和标量替换,只是纯粹的消耗性能。
对象的创建
- Java对象是怎么存储的
- 对象头里面有什么
创建对象的方式
- new
- Class的newInstance():反射方式,只能调用无参构造方法,权限必须是public
- Constructor的newInstance(args):反射方式,可以调用无参、有参构造方法,权限没有要求
- 使用clone()方法:不调用构造方法,当前类需要实现Cloneable接口,实现clone()方法
- 反序列化:从文件、网络获取一个对象的二进制流
- 第三方库Objenesis
创建对象的步骤
- 判断对象对应的类是否被加载、链接、初始化
- 为对象分配内存
- 处理并发安全问题
- 初始化分配到的空间
- 设置对象头
- 执行init方法进行初始化
Java对象内存布局
对象头(Header)
- 运行时元数据(Mark Word):哈希值、GC分代年龄、锁状态标志、线程持有的锁、偏向锁线程ID、偏向锁时间戳
- 类型指针:指向类元数据InstanceKlass,确定该对象所属类型
- 如果是数组,还需要记录数组长度
实例数据(Instance Data)
对象真正存储的有效信息,包括程序代码中定义的各种类型的字段,包括从父类继承下来的和本身拥有的字段。
对齐填充(Padding)
变量访问对象
JVM是如何通过栈帧的对象引用访问到对象实例呢?