JVM内存模型与内存分配
JVM内存模型
Young GC过程对象挪动后,对象引用的修改
比如扫描S0区域,找到GC Root引用的非垃圾对象,将这些对象复制到S1区域,扫描完成后,将S0区域的对象分配指针移动到S0区域的起始位置,S0区域的垃圾对象不直接清理,当在S0分配新对象时,原有区域的对象就被清除了。
Young GC会记录非垃圾对象的引用,如果引用的对象被复制到新的内存地址,最后会一并更新对象引用指向新地址。
JVM内存参数
- -Xms:设置堆初始可用大小,默认物理内存的1/64;
- -Xmx:设置堆最大可用大小,默认物理内存的1/4;
- -Xmn:设置新生代大小;
- -XX:NewRatio:默认为2,表示新生代占年老代的1/2,占整个堆的1/3;
- -XX:SurvivorRatio:默认为8,表示一个survivor区占用Eden区1/8的内存,新生代1/10内存;
- -XX:MetaspaceSize:设置元空间触发FullGC的初始大小,默认为21M字节。到达该值就会触发FullGC进行类型卸载,GC会对该值进行调整:如果释放了大量空间,就适当减小该值;如果释放了很少的空间,在不超过MaxMetaspaceSize的情况下适当增大该值;
- -XX:MaxMetaspaceSize:设置元空间最大值,默认-1,不限制大小,受限于本地物理内存大小,生产环境必须要设置;
- -Xss:设置线程栈的大小;
JVM对象内存分配
对象创建过程
创建对象主要流程:
1. 类加载检查
检查该类是否已经被加载过,如果没有就加载该类。
2. 分配内存
JVM为新生对象分配内存,类的大小在类加载完成后就可确定。
分配对象有两种方法,并且需要注意在并发情况下,可能会发生正在给对象A分配内存,指针还没来得及修改,对象B又使用了原来的指针进行分配内存。
两种方法分配内存
-
指针碰撞(Bump the Pointer)
JVM默认使用这种方式分配内存。如果堆内存都有有序的,即用过的内存在一边,空闲的内存在另一边,就可以用一个指针记录已用过的内存的末尾地址,当为新对象分配内存时,只需要将该指针往后挪动该对象大小的位置即可。
-
空闲列表(Free List)
如果堆内存乱序的,已使用的内存和未使用的内存相互交错,这时就需要一个列表来记录哪些内存块是空闲的,当分配内存时只需要在空闲列表中找到一块足够大的内存空间分配给对象,然后更新列表。
处理并发
-
JVM使用CAS+失败重试解决分配内存的并发情况;
-
本地线程分配缓冲 Thread Local Allocation Buffer,TLAB
每个线程预先在堆中划分一小块区域,在这里为新生对象分配内存,将内存分配的动作在各自线程内部进行,就不会存在并发情况了。
通过-XX:+/-UseTLAB开启/关闭TLAB(JVM默认开启),-XX:TLABSize指定TLAB大小。
3. 初始化零值
为对象各个字段赋零值,即各种数据类型对应的默认值。保证了Java对象的实例字段在没有赋值时就可以直接使用。
4. 设置对象头
对象在内存中的存储结构分为:
-
对象头(Header)
存储对象的运行时数据:哈希码、GC分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳等。
还有对象指向类在元数据中的指针,表示这个对象是哪个类的实例。 -
实例数据(Instance Data)
-
对齐填充(Padding)
5. 执行init方法
为属性赋值,执行构造方法。
指针压缩
从JDK1.6 update14开始,在64位系统中,JVM支持指针压缩,通过JVM配置参数-XX:+/-UseCompressedOops来开启/关闭指针压缩。
为什么要指针压缩?
- 减少64位系统下内存的消耗,用64位指针表示32位指针,内存会多占用1.5倍,用64位指针在内存中移动数据,会占用较大带宽,并影响GC;
- 堆内存小于4G时,不需要开启指针压缩,JVM会去除高32位地址;
- 堆内存高于32G时,会强制使用64位(8字节)指针;
对象内存分配
对象栈上分配
通过对象逃逸分析确定对象不会被外部访问,这个对象就可以在栈上分配内存,这样对象就可以在栈帧出栈时清除,从而减小GC压力。
栈上分配依赖于逃逸分析和标量替换。
对象逃逸分析
当方法内的对象作用域只在方法中时,比如没有将该对象作为参数调用其他方法或者返回该对象。
这种对象可以分配在栈内存中,在方法结束后,栈帧出栈之后,这个对象就是垃圾对象了,就可以跟随栈内存以被回收掉,减小GC压力。
开启/关闭逃逸分析:-XX:+/-DoEscapeAnalysis,JDK7之后默认开启。
标量替换
通过逃逸分析确定对象不会被方法外部访问,并且对象可以进一步分解时,JVM不会创建该对象,而是在将该对象分解成若干个被这个方法使用的成员变量所替换。
这些成员变量在栈上分配内存空间,这样就可以避免因为没有一块足够大的连续的内存空间导致对象内存不能分配。
开启/关闭标量替换:-XX:+/-EliminateAllocations,JDK7之后默认开启。
变量与聚合量
- 标量是不可被进一步分解的量,比如Java中int、long等基本数据类型。
对象在Eden区分配
一般情况下,对象在新生代Eden区分配内存,当Eden区没有足够的内存空间分配时,JVM就会发起一次Young GC。将Eden区非垃圾对象挪到为空的Survivor区,这些存活的对象年龄会+1;下一次Eden区又满了,再次Young GC时,回收Eden和Survivor区的垃圾对象,将存活对象挪动到另一个为空的Survivor区,将原来Survivor区清空,对象年龄+1,当对象年龄达到一定年龄时就进入老年代。
Eden区的对象存活年龄一般都很小,所以让Eden区域大些,Survivor区够用即可。
- Young GC:发生在Eden区,非常频繁,回收速度也很快;
- Full GC:回收老年代、年轻代和方法区的垃圾,Full GC比Young GC慢10倍以上;
Eden与Survivor区8:1:1
Eden与Survivor区大小默认比例为8:1:1
可以通过-XX:+/-UseAdaptiveSizePolicy(默认开启)开启/关闭8:1:1比例自动变化。
大对象直接进入老年代
大对象指需要大量连续内存空间的对象,比如数组、字符串等。
在Serial和ParNew收集器下,通过参数-XX:PretenureSizeThreshold(字节)设置大对象的大小,如果对象大小超过这个值就会直接进入老年代,不会进入年轻代。
这样是为了避免为大对象分配内存时频繁的复制导致效率降低。
长期存活对象进入老年代
Eden区的对象在经过一次Young GC后还能存活下来,并且大小能被Survivor容纳,就会被移动到Survivor区,对象年龄age+1,当这个age>-XX:MaxTenuringThreshold(默认为15岁,CMS默认为6岁)参数值时,该对象就会进入老年代。
对象动态年龄判断机制
在Survivor区中,一批对象的大小总和大于该Survivor区的-XX:TargetSurvivorRatio参数值(默认50%),那么大于等于这批对象最大年龄的对象就可以直接进入老年代了。
这个机制是让那些可能是长期存活的对象尽早进入老年代。
一般发生在Young GC后。
老年代空间分配担保机制
-XX:+/-HandlePromotionFailure,JDK8默认开启。
老年代在进行Full GC后还是没有足够空间存放年轻代过来的对象,就会发生OOM。
如果Young GC之后存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发Full GC,Full GC后如果还是没有空间放Young GC之后的存活对象,则也会发生OOM。
对象内存回收
可达性分析算法
GC Root作为起点,从这些节点开始搜索引用的对象,在这条引用链上的对象都标记为非垃圾对象,其余对象标记为垃圾对象。
GC Root根节点:线程栈本地变量、静态变量、本地方法栈的变量。
常见引用类型
强引用
普通的变量引用
public A a = new A();
软引用
GC之后,如果发现释放不出空间存放新对象,就会清除这些软引用对象。
可用来实现对内存敏感的高速缓存。
public SoftReference<A> a = new SoftReference<A>(new A());
弱引用
弱引用跟没引用差不多,GC会直接回收掉。
public WeakReference<A> a = new WeakReference<A>(new A());
虚引用
幻影引用,最弱的引用关系。
public PhantomReference<A> a = new PhantomReference<>(new A(), new ReferenceQueue<A>());
无用的类
方法区回收无用的类,无用的类满足以下三个条件:
- 该类的所有对象实例都已经被回收,在Heap中没有该类的任何实例;
- 加载该类的ClassLoader已经被回收;
- 该类对应的java.lang.Class对象没有被引用,无法通过反射访问该类的方法。