3.JVM内存分配
3.1.内存分配概述
3.2.内存分配–Eden区域
3.3.内存分配–大对象直接进老年代
3.3.1.背景
3.3.2.解析
3.4.内存分配–长期存活的对象进去老年代
3.5.内存分配–空间分配担保
3.5.1.堆空间参数
3.5.2.-XX:HandlePromotionFailure
3.6.内存分配–逃逸分析与栈上分配
3.6.1.逃逸分析
3.6.1.1.方法逃逸
3.6.1.2.线程分配
3.6.2.栈上分配
3.6.3.逃逸分析/栈上分配的优势分析
3.6.3.1.同步消除
3.6.4.标量替换
3.6.5.什么情况下会发生逃逸?
3.7.直接内存
3.8.Java内存区域-直接内存和运行时常量池
3.8.1.运行时常量池简介
3.8.2.Class文件中的信息常量池
3.8.3.常量池的好处
3.8.4.基本类型的包装类和常量池
3.9.对象在内存中的布局-对象的创建
3.10.探究对象的结构
3.11.深度理解对象的访问定位
3.12.Java对象访问方式
3.12.1.通过句柄访问
3.12.2.通过直接指针访问
3.13.对象分配内存的策略
3.13.1.线程安全问题
3.13.1.1.本地线程分配缓冲----TLAB
3.13.1.2.TLAB生命周期
3.13.1.3.TLAB的大小
3.13.1.4.总结
3.13.1.5.参数总结
3.14.垃圾回收-判断对象是否存活算法-引用计数法详解
3.15.垃圾回收-判断对象是否存活算法-可达性分析法详解
3.15.1.可达性分析算法
3.15.2.finalize()方法最终判定对象是否存活
3.15.3.Java引用
3.15.3.1.强引用
3.15.3.2.软引用
3.15.3.3.弱引用
3.15.3.4.虚引用
3.15.3.5.软引用和弱引用进一步说明
3.15.3.6.虚引用进一步说明:
Java对象所占用的内存主要在堆上实现,因为堆是线程共享的,因此在堆上分配内存时需要进行加锁,这就导致了创建对象的开销比较大。当堆上空间不足时,会触发GC,如果GC后空间仍然不足,则会抛出OutOfMemory异常。
为了提升内存分配效率,在年轻代的Eden区HotSpot虚拟机使用了两种技术来加快内存分配 ,分别是bump-the-pointer和TLAB(Thread-Local Allocation Buffers)。由于Eden区是连续的,因此bump-the-pointer技术的核心就是跟踪最后创建的一个对象,在对象创建时,只需要检查最后一个对象后面是否足够的内存即可,从而大大加快内存分配速度;而对于TLAB技术是对于多线程而言的,它会为每个新创建的线程在新生代的Eden Space上分配一块独立的空间,这块空间成为TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行情况计算而得。通过XX:TLABWasteTargetPercent来设置其可占用的Eden Space的百分比,默认是1%。在TLAB上分配内存不需要加锁,一般JVM会优先在TLAB上分配内存,如果对象过大或者TLAB空间已经用完,则仍然在堆上进行分配。因此,在编写程序时,多个小对象比大的对象分配起来效率更高。可在启动参数上增加-XX:+PrintTLAB来查看TLAB空间的使用情况。
对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次Minor GC后存活了下来),则会被复制到年老代,年老代的空间一般比年轻代大,能存放更多的对象,在年老代上发生的GC次数也比年轻代少。当年老代内存不足时,将执行Major GC,也叫 Full GC。
可以使用**-XX:+UseAdaptiveSizePolicy**开关来控制是否采用动态控制策略,如果动态控制,则动态调整Java堆中各个区域的大小以及进入老年代的年龄。
如果对象比较大(比如长字符串或大数组),年轻代空间不足,则大对象会直接分配到老年代上(大对象可能触发提前GC,应少用,更应避免使用短命的大对象)。用-XX:PretenureSizeThreshold来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。
Java执行的时候,默认使用parallel收集器。对象优先到Eden中:
案例:
创建Main类:
package com.toto.jvm.demo;
public class Main {
public static void main(String[] args) {
byte[] b1 = new byte[4 * 1024 * 1024];
}
}
在Eclipse中配置VM arguments参数(-verbose:gc -XX:+PrintGCDetails):
Eden是新生代上的一部分区域,当运行上面的代码的时候,GC日志输出中可以看到优先到Eden,输出结果如下:
上面输出ParOldGen,说明使用的parallel收集器。
使用serialGC的时候,打印的gc日志(-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC):
运行后输出结果:
由于上面的b1分配的内存是4 * 1024 * 1024 即4M
而上图可以看到只有eden space 138816K,占比8%。可以得出结论:创建的对象优先进入eden区域。
当把b1变成200M时(即大对象):
说明:大对象直接分配到老年代。
再如案例:
package com.toto.jvm.demo;
public class Main {
public static void main(String[] args) {
byte[] b1 = new byte[5 * 1024 * 1024];
byte[] b2 = new byte[4 * 1024 * 1024];
byte[] b3 = new byte[4 * 1024 * 1024];
byte[] b4 = new byte[4 * 1024 * 1024];
}
}
修改VM参数:
-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
输出结果:
-XX:SurvivorRatio=8表示Survivor是Eden的1/8。
讲到大对象主要指字符串和数组,虚拟机提供了一个-XX:PretenureSizeThreshold参数,大于这个值的参数直接在老年代分配。
这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存复制(新生代采用复制算法)。
有两种情况,对象会直接分配到老年代:
如果在新生代分配失败且对象是一个不含任何对象引用的大数组,可被直接分配到老年代。通过在老年代的分配避免新生代的一次垃圾回收。
XX:PretenureSizeThreshold=<字节大小>可以设分配到新生代分配内存。任何比这个大的对象都不会尝试在新生代分配,将在老年代分配内存。
PretenureSizeThreshold默认值是0,意味着任何对象都会现在新生代分配内存。
案例:
设置虚拟机参数:
-verbose:gc -XX:+PrintGCDetails -Xms2048M -Xmx2048M -Xmn1024M -XX:SurvivorRatio=8 -XX:+UseConcMarkSweepGC
-Xms表示初始化堆内存
-Xmx表示最大堆内存
-Xmn表示新生代的内存
-XX:SurvivorRatio=8表示新生代的Eden占8/10,S1和S2各占1/10
因此Eden的内存大小为:0.8 * 1024 * 1024 * 1024字节 约为819 * 1024 * 1024
上代码:
package com.toto.jvm.demo2;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
public class Main {
public static void main(String[] args) {
//734003216
byte[] array = new byte[700 * 1024 * 1024];
for (MemoryPoolMXBean memoryPoolMXBean : ManagementFactory.getMemoryPoolMXBeans()) {
System.out.println(memoryPoolMXBean.getName() + " 总量:" + memoryPoolMXBean.getUsage().getCommitted() + " 使用的内存:" + memoryPoolMXBean.getUsage().getUsed());
}
}
}
package com.toto.jvm.demo2;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
public class Main {
public static void main(String[] args) {
//734003216
byte[] array = new byte[900 * 1024 * 1024];
for (MemoryPoolMXBean memoryPoolMXBean : ManagementFactory.getMemoryPoolMXBeans()) {
System.out.println(memoryPoolMXBean.getName() + " 总量:" + memoryPoolMXBean.getUsage().getCommitted() + " 使用的内存:" + memoryPoolMXBean.getUsage().getUsed());
}
}
}
用法: -XX:MaxTenuringThreshold=15
该参数主要是控制新生代需要经历多少次GC晋升到老年代中的最大阈值。在JVM中用4个bit存储(放在对象头中),(1111)所以其最大值是15。
但并非意味着,对象必须要经历15次YGC才会晋升到老年代中。例如,当Survivor区空间不够时,便会提前进入到老年代中,但这个次数一定不大于设置的最大阈值。
那么JVM到底是如何来计算S区对象晋升到Old区的呢?
首先介绍另一个重要的JVM参数:
-XX:TargetSurvivorRatio:一个计算期望S区存活大小(Desired survivor size)的参数。默认值为50,即50%。
当一个S区中所有的age对象的大小如果大于等于Desired survivor size,则重新计算threshold,以age和MaxTenuringThreshold两者的最小值为准。
以一个Demo为例。设置VM参数值:
-Xmx200M -Xmn50m -XX:TargetSurvivorRatio=60 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:MaxTenuringThreshold=3 -XX:+PrintTenuringDistribution
代码:
package com.toto.jvm.demo3;
/**
* -Xmx200M -Xmn50m -XX:TargetSurvivorRatio=60 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:MaxTenuringThreshold=3
* 最小堆为50M,默认SurvivorRatio为8,那么可以知道Eden区为40M,S0和S1为5M
*
* 可以在JVM启动参数中加上-XX:+PrintTenuringDistribution,该参数可以输出age的额外信息。
*/
public class App {
public static void main(String[] args) throws InterruptedException {
// main方法作为主线程,变量不会被回收
byte[] byte1 = new byte[1 * 1024 * 1024];
byte[] byte2 = new byte[1 * 1024 * 1024];
YGC(40);
Thread.sleep(3000);
YGC(40);
Thread.sleep(3000);
YGC(40);
Thread.sleep(3000);
// 这次再ygc时, 由于byte1和byte2的年龄经过3次ygc后已经达到3(-XX:MaxTenuringThreshold=3),
// 所以会晋升到old
YGC(40);
// ygc后, s0(from)/s1(to)的空间为0
Thread.sleep(3000);
// 达到TargetSurvivorRatio这个比例指定的值,即5M(S区)*60%(TargetSurvivorRatio)=3M(Desired survivor size)
byte[] byte4 = new byte[1 * 1024 * 1024];
byte[] byte5 = new byte[1 * 1024 * 1024];
byte[] byte6 = new byte[1 * 1024 * 1024];
// 这次ygc时, 由于s区已经占用达到了60%(-XX:TargetSurvivorRatio=60),
// 所以会重新计算对象晋升的min(age, MaxTenuringThreshold) = 1
YGC(40);
Thread.sleep(3000);
// 由于前一次ygc时算出age=1, 所以这一次再ygc时, byte4, byte5, byte6就要晋升到Old,
// 而不需要等MaxTenuringThreshold这么多次, 此次ygc后, s0(from)/s1(to)的空间再次为0,
// 对象全部晋升到old
YGC(40);
Thread.sleep(3000);
System.out.println("GC end!");
}
// 塞满Eden区,局部变量会被回收,作为触发GC的小工具
private static void YGC(int edenSize) {
for (int i = 0; i < edenSize; i++) {
byte[] byte1m = new byte[1 * 1024 * 1024];
}
}
}
输出结果:
2021-05-03T10:53:15.791+0800: [GC (Allocation Failure) 2021-05-03T10:53:15.791+0800: [ParNew
Desired survivor size 3145728 bytes, new threshold 3 (max 3)
- age 1: 2649352 bytes, 2649352 total
: 40551K->2623K(46080K), 0.0022131 secs] 40551K->2623K(199680K), 0.0023103 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2021-05-03T10:53:18.797+0800: [GC (Allocation Failure) 2021-05-03T10:53:18.797+0800: [ParNew
Desired survivor size 3145728 bytes, new threshold 3 (max 3)
- age 1: 168 bytes, 168 total
- age 2: 2647416 bytes, 2647584 total
: 43362K->2824K(46080K), 0.0025757 secs] 43362K->2824K(199680K), 0.0026316 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2021-05-03T10:53:21.805+0800: [GC (Allocation Failure) 2021-05-03T10:53:21.805+0800: [ParNew
Desired survivor size 3145728 bytes, new threshold 3 (max 3)
- age 2: 168 bytes, 168 total
- age 3: 2647416 bytes, 2647584 total
: 43562K->2694K(46080K), 0.0009461 secs] 43562K->2694K(199680K), 0.0009973 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2021-05-03T10:53:24.808+0800: [GC (Allocation Failure) 2021-05-03T10:53:24.808+0800: [ParNew
Desired survivor size 3145728 bytes, new threshold 3 (max 3)
- age 3: 168 bytes, 168 total
: 43432K->104K(46080K), 0.0048805 secs] 43432K->2740K(199680K), 0.0049507 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2021-05-03T10:53:27.820+0800: [GC (Allocation Failure) 2021-05-03T10:53:27.821+0800: [ParNew
Desired survivor size 3145728 bytes, new threshold 1 (max 3)
- age 1: 3145776 bytes, 3145776 total
: 40842K->3072K(46080K), 0.0028666 secs] 43478K->5708K(199680K), 0.0030672 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2021-05-03T10:53:30.827+0800: [GC (Allocation Failure) 2021-05-03T10:53:30.827+0800: [ParNew
Desired survivor size 3145728 bytes, new threshold 3 (max 3)
: 43811K->0K(46080K), 0.0033850 secs] 46447K->5708K(199680K), 0.0034430 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
GC end!
Heap
par new generation total 46080K, used 13910K [0x00000000f3800000, 0x00000000f6a00000, 0x00000000f6a00000)
eden space 40960K, 33% used [0x00000000f3800000, 0x00000000f45959a0, 0x00000000f6000000)
from space 5120K, 0% used [0x00000000f6000000, 0x00000000f6000000, 0x00000000f6500000)
to space 5120K, 0% used [0x00000000f6500000, 0x00000000f6500000, 0x00000000f6a00000)
concurrent mark-sweep generation total 153600K, used 5708K [0x00000000f6a00000, 0x0000000100000000, 0x0000000100000000)
Metaspace used 2595K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 288K, capacity 386K, committed 512K, reserved 1048576K
============================================================================
另外的一篇文章的说明:
-XX:MaxTenuringThreshold设置的是年龄阈值,默认15(对象被复制的次数)
JVM为每个对象定义了一个对象年龄(Age)计数器, 对象在Eden出生如果经第一次Minor GC后仍然存活, 且能被Survivor容纳的话, 将被移动到Survivor空间中, 并将年龄设为1. 以后对象在Survivor区中每熬过一次Minor GC年龄就+1. 当增加到设置的阀值时将会晋升到老年代。
但有一个疑惑,为什么我设置-XX:MaxTenuringThreshold足够大了防止大量对象进入老年区,虽然进入老年区的对象减少了,但还是有?
因为如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代。
主要使用的JVM参数配置是:-XX:HandlePromotionFailure,使用空间分配担保的时候使用-XX:+HandlePromotionFailure,不使用分配担保的时候使用-XX:-HandlePromotionFailure。
官网地址:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
-XX:+PrintFlagsInitial : 查看所有的参数默认初始值
-XX:+PrintFlagsFinal: 查看所有的参数的最终值(可能会存在修改,不再是初始值)
-Xms: 初始堆空间内存(默认为物理内存的1/64)
-Xmx: 最大堆空间内存(默认为物理内存的1/4)
-Xmn: 设置新生代的大小(初始值及最大值)。
-XX:NewRatio: 配置新生代与老年代在堆结构的占比。
-XX:SurvivorRatio: 设置新生代中Eden和S0/S1空间的比例。
-XX:MaxTenuringThreshold: 设置新生代垃圾的最大年龄。
-XX:+PrintGCDetails: 输出详细的GC处理日志
打印gc简要信息:(1) -XX:+PrintGC (2) -verbose:gc
-XX:HandlePromotionFailure: 是否设置空间分配担保
JDK7及以后这个参数就失效了。
只要老年代的连续空间大于新生代对象的总大小或者历次晋升到老年代的对象的平均大小就进行MinorGC,否则FullGC。
内存逃逸主要是对象的动态作用域的改变而引起的,故而内存逃逸的分析就是分析对象的动态作用域。
发生逃逸行为的情况分为两种:方法逃逸和线程逃逸
逃逸是指在某个方法之内创建的对象,除了在方法体之内被引用之外,还在方法体之外被其它变量引用到;这样带来的后果是在该方法执行完毕之后,该方法中创建的对象将无法将GC回收,由于其被其它变量引用。正常的方法调用中,方法体中创建的对象将在执行完毕之后,将回收其中创建的对象;故由于无法回收,即成为逃逸。
如果对象发生逃逸,那会分配到堆中。(因为对象发生了逃逸,就代表这个对象可以被外部访问,换句话说,就是可以共享,能共享数据的,无非就是堆或方法区,这就是堆。)
如果对象没发生逃逸,那会分配到栈中。(因为对象没发生逃逸,那就代表这个对象不能外部访问,换句话说,就是不可共享,这里就是栈。)
package com.toto.jvm.demo4;
import jvm.test;
public class Main {
public static Object obj;
public void globalVariableEscape() {
// 给全局变量赋值,发生逃逸
obj = new Object();
}
public Object methodEscape() {
// 方法返回值,发生逃逸
return new Object();
}
public void instanceEscape() {
// 实例引用,发生逃逸
test(this);
}
public void getInstance() {
//对象的作用域只在当前方法中有效,没有发生逃逸
Object obj1 = new Object();
}
}
运行java时传递jvm参数-XX:+DoEscapeAnalysis
栈上分配与逃逸分析的关系
进行逃逸分析之后,产生的后果是所有的对象都将由栈上分配,而非从JVM内存模型中的堆来分配。
栈上分配可以提升代码性能,降低在多线程情况下的锁使用,但是会受限于其空间的大小。
分析找到未逃逸的变量,将变量类的实例化内存直接在栈里分配(无需进入堆),分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。
能在方法内创建对象,就不要再方法外创建对象。
1.什么是栈上分配?
栈上分配主要是指在java程序的执行过程中,在方法体中声明的变量以及创建的对象,将直接从该线程所使用的栈中分配空间。一般而言,创建对象都是从堆中来分配的,这里是指在栈上来分配空间给新创建的对象。
2.什么是逃逸?
逃逸是指在某个方法之内创建的对象,除了在方法体之内被引用之外,还在方法体之外被其它变量引用到;这样带来的后果是在该方法执行完毕之后,该方法中创建的对象将无法被GC回收,由于其被其它变量引用。正常的方法调用中,方法体中创建的对象将在执行完毕之后,将回收其中创建的对象;故由于无法回收,即成为逃逸。
当方法创建了一个对象之后,这个对象被外部方法所调用,这个时候方法运行结束要进行GC时,本该方法的对象被回收,却发现该对象还存活着,没法回收,则称为"方法逃逸"
简单来说:就是当前方法创建的对象,本该是当前方法的栈帧所管理,却被调用方所使用,可以称之为内存逃逸。
直接将对象进行返回出去,该对象很可能被外部线程所访问,如:赋值给变量等,则称为”线程逃逸”。
当我们创建一个对象的时候,会立马想到该对象是会存储到堆空间中的,而垃圾回收机制会在堆空间中回收不再使用的对象,但是筛选可回收对象,还有整理对象都需要消耗时间,如果能够通过逃逸分析确定某些对象不会逃出到方法外的话,那么就可以直接让这个对象在栈空间分配内存,这样该对象会随着方法的执行完毕自动进行销毁。
栈上分配主要是指在Java程序的执行过程中,在方法体中声明的变量以及创建的对象,将直接从该线程所使用的栈中分配空间。一般而言,创建对象都是从堆中来分配的,这里是指在栈上分配空间给新建的对象。
如果能够证明一个对象,不会进行逃逸到方法或线程外的话,则可以对该变量进行优化。
优势表现在以下两个方面:
消除同步:线程同步的代价是相当高的,同步的后果是降低并发性和性能。逃逸分析可以判断出某个对象是否始终只被一个线程访问,如果只被一个线程访问,那么对该对象的同步操作就可以转化成没有同步保护的操作,这样就能大大提高并发程度和性能。
矢量替代:逃逸分析方法如果发现对象的内存存储结构不需要连续进行的话,就可以将对象的部分甚至全部保存在CPU寄存器内,这样能大大提高访问速度。
劣势:
栈上分配受限于栈的空间大小,一般自我迭代类的需求以及大的对象空间需求操作,将导致栈的内存溢出;故只适用于一定范围之内的内存范围请求。
线程同步本身比较耗时,若确定了一个变量不会逃逸出线程,无法被其他线程访问到,那这个变量的读写就不会存在竞争,则可以消除对该对象的同步锁。
1、标量是指不可分割的量,如java中基本数据类型和引用类型,都不能够再进一步分解,他们就可以成为称为标量。
2、若一个数据可以继续分解,那就称之为聚合量,而对象就是典型的聚合量。
3、若逃逸分析证明一个对象不会逃逸出方法,不会被外部访问,并且这个对象是可以被分解的,那程序在真正执行的时候可能不创建这个对象,而是直接创建这个对象分解后的标量来代替。这样就无需在对对象分配空间了,只在栈上为分解出的变量分配内存即可。
注意:
逃逸分析是比较耗时的,所以性能未必提升很多,因为其耗时性,采用的算法都是不那么准确但是时间压力相对较小的算法来完成的,这就可能导致效果不稳定,要慎重。
由于HotSpot虚拟机目前的实现方法导致栈上分配实现起来比较复杂,所以HotSpot虚拟机中暂时还没有这项优化。
相关JVM参数:
-XX:+DoEscapeAnalysis 开启逃逸分析、
-XX:+PrintEscapeAnalysis 开启逃逸分析后,可通过此参数查看分析结果。
-XX:+EliminateAllocations 开启标量替换。
-XX:+EliminateLocks 开启同步消除。
-XX:+PrintEliminateAllocations 开启标量替换后,查看标量替换情况。
案例:
package com.toto.jvm.demo4;
public class StackAllocation {
public StackAllocation obj;
/**
* 方法返回StackAllocation对象,发生逃逸
* @return
*/
public StackAllocation getInstance() {
return obj == null ? new StackAllocation() : obj;
}
/**
* 为成员属性赋值,发生逃逸
*/
public void setObj() {
this.obj = new StackAllocation();
}
/**
* 对象的作用域仅在当前方法中有效,没有发生逃逸
*/
public void useStackAllocation() {
StackAllocation s = new StackAllocation();
}
/**
* 引用成员变量的值,发生逃逸
*/
public void useStackAllocation2() {
StackAllocation s = getInstance();
}
}
查看一下什么是直接内存。
NIO中直接分配直接内存。
运行时常量池(Runtime Constant Pool),它是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到常量池中。
运行时常量是相对于常量来说的,它具备一个重要特征是:动态性。当然,值相同的动态常量与我们通常说的常量只是来源不同,但是都是储存在池内同一块内存区域。Java语言并不要求常量一定只能在编译期产生,运行期间也可能产生新的常量,这些常量被放在运行时常量池中。这里所说的常量包括:基本类型包装类(包装类不管理浮点型,整型只会管理-128到127)和String(也可以通过**String.intern()**方法可以强制将String放入常量池)
在Class文件结构中,最头的4个字节用于存储Megic Number,用于确定一个文件是否能被JVM接受,再接着4个字节用于存储版本号,前2个字节存储次版本号,后2个存储主版本号,再接着是用于存放常量的常量池,由于常量的数量是不固定的,所以常量池的入口放置一个U2类型的数据(constant_pool_count)存储常量池容量计数值。
常量池主要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References),字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等,符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:
类和接口的全限定名
字段名称和描述符
方法名称和描述符
常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。
节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间。
节省运行时间:比较字符串时,比equals()快。对于两个引用变量,只用判断引用是否相等,也就判断实际值是否相等。
双等号==的含义
基本数据类型之间应用双等号,比较的是他们的数值。
复合数据类型(类)之间应用双等号,比较的是他们在内存中的存放地址。
java中基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean。这5种包装类默认创建了数值[-128, 127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。两种浮点数类型的包装类Float,Double并没有实现常量池技术。
1)Integer与常量池
Integer i1 = 40;
Integer i2 = 40;
Integer i3 = 0;
Integer i4 = new Integer(40);
Integer i5 = new Integer(40);
Integer i6 = new Integer(0);
System.out.println("i1=i2 " + (i1 == i2));
System.out.println("i1=i2+i3 " + (i1 == i2 + i3));
System.out.println("i1=i4 " + (i1 == i4));
System.out.println("i4=i5 " + (i4 == i5));
System.out.println("i4=i5+i6 " + (i4 == i5 + i6));
System.out.println("40=i5+i6 " + (40 == i5 + i6));
i1=i2 true
i1=i2+i3 true
i1=i4 false
i4=i5 false
i4=i5+i6 true
40=i5+i6 true
解释:
Integer i1 = 40; java在编译的时候会直接将代码封装成Integer i1 = Integer.valueOf(40); 从而使用常量池中的对象。
Integer i4 = new Integer(40); 这种情况下会创建新的对象。
语句i4 == i5 + i6,因此+这个操作符不适用于Integer对象,首先i5和i6进行自动拆箱操作,进行数值相加,即i4 == 40。然后Integer对象无法与数值进行直接比较,所以i4自动拆箱转为int值40,最终这条语句转为40 == 40进行数值比较。
2)String与常量池-普通方法赋值
String str1 = "abcd";
String str2 = new String("abcd");
System.out.println(str1==str2);//false
String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";
String str4 = str1 + str2;
System.out.println("string" == "str" + "ing");// true
System.out.println(str3 == str4);//false
String str5 = "string";
System.out.println(str3 == str5);//true
解释:
“abcd”是在常量池中拿对象,new String(“abcd”)是直接在堆内存空间创建一个新的对象。只要使用new方法,便需要创建的对象。
连接表达式+,只有使用引号包含文本的方式创建的String对象之间使用”+”连接产生的新对象才会被加入常量池中。
对于字符串变量的”+”连接表达式,它所产生的新对象都不会被加入字符串池中,其属于在运行时创建的字符串,具有独立的内存地址,所以不引用自同—String对象。
3)String与常量池-静态方法赋值
package com.toto.jvm.demo5;
public class Main {
/** 常量A **/
public static final String A;
/** 常量B **/
public static final String B;
static {
A = "ab";
B = "cd";
}
public static void main(String[] args) {
// 将两个常量用 + 连接对s进行初始化
String s = A + B;
String t = "abcd";
if (s == t) {
System.out.println("s等于t,它们是同一个对象");
} else {
System.out.println("s不等于t,它们不是同一个对象");
}
}
}
输出结果:
s不等于t,它们不是同一个对象
解释:
s不等于t,它们不是同一个对象。A和B虽然被定义为常量,但是它们都没有马上被赋值。在运算出s的值之前,他们何时被赋值,以及被赋予什么样的值,都是个变量。因此A和B在赋值之前,性质类似于一个变量。那么s就不能在编译期被确定,而只能运行时被创建了。
4)String与常量池 - intern方法
package com.toto.jvm.demo6;
public class Main {
public static void main(String[] args) {
String s1 = new String("计算机");
String s2 = s1.intern();
String s3 = "计算机";
System.out.println("s1 == s2 ? " + (s1 == s2));
System.out.println("s3 == s2 ? " + (s3 == s2));
/**
* 结果是:
* s1 == s2 ? false
* s3 == s2 ? true
**/
}
}
解释:
String的intern()方法会查找在常量池中是否存在一份equal相等的字符串,如果有则返回该字符串的引用,如果没有则添加自己的字符串进入常量。
5)String与常量池 - 延伸
String s1 = new String(“xyz”); //创建了几个对象?
解释:
考虑类加载阶段和实际执行时。
类加载对一个类只会进行一次。”xyz”在类加载时就已经创建并驻留了(如果该类被加载之前已经有”xyz”字符串被驻留过则不需要重新创建用于驻留的”xyz”实例)。驻留的字符串是放在全局共享的字符串常量池中的。
在这段代码后连续被运行的时候,”xyz”字面量对应的String实例已经固定了,不会再被重新创建。所以这段代码将常量池中的对象复制一份放在到heap中,并且把heap中的这个对象的引用交给s1持有。
intern()会把值搬到运行时常量池中。它是一个native方法。
如果无法申请内存,报:OutOfMemoryError
对象创建 步骤
1、new类名
2、根据new的参数在常量池中定位一个类的符号引用。
3、如果没有找到这个符号引用,说明类还没加被加载,则进行类的加载、解析和初始化。
4、虚拟机为对象分配内存(位于堆中)
5、将分配的内存初始化为零值(不包括对象头)
6、调用对象的方法。
已经创建对象,如何找到对象呢?就涉及到访问定位的问题
有两种方式(百度一下):
1、使用句柄
2、直接指针
使用句柄池的用途
一般来说,一个Java的引用访问涉及到3个内存区域:JVM栈,堆,方法区。以最简单的本地变量引用:Object objRef = new Object()为例:
Object objRef表示一个本地引用,存储在JVM栈的本地变量表中,表示一个reference类型数据;
new Object()作为实例对象数据存储在堆中;
堆中还记录了能够查询到此Object对象的类型数据(接口、方法、field、对象类型等)的地址,实际的数据则存储在方法区中;
在Java虚拟机规范中,只规定了指向对象的引用,对于通过reference类型引用访问具体对象的方式并未做规定,不过目前主流的实现方式主要有两种:
通过句柄访问的实现方式中,JVM堆中会划分单独一块内存区域作为句柄池,句柄池中存储了对象实例数据(在堆中)和对象类型数据(在方法区中)的指针。这种实现方法由于用句柄表示地址,因此十分稳定。
通过直接指针访问的方式中,reference中存储的就是对象在堆中的实际地址,在堆中存储的对象信息中包含了在方法区中的相应类型数据。这种方法最大的优势是速度快,在HotSpot虚拟机中用的就是这种方式。
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump thePointer)。如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(FreeList)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
给对象分配的方式:
方式一:指针碰撞
方式二:空间列表
下面两张图可以解释指针碰撞和空闲列表:
指针碰撞:
空间列表:
1、实现线程同步,加锁(但:执行效率低)
2、本地线程分配缓冲TLAB: (每个线程分配一定一定的内存)
TLAB是虚拟机在堆内存的划分出来的一块专用空间,是线程专属的。在TLAB启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。
ps:这里说线程独享的堆内存,只是在“内存分配”这个动作上是线程独享的,至于在读取、垃圾回收等动作上都是线程共享的。即是指其他线程可以在这个区域读取、操作数据,但是无法在这个区域中分配内存。
在分代收集的垃圾回收器中,TLAB是在eden区分配的。TLAB 是从堆上 Eden 区的分配的一块线程本地私有内存。线程初始化的时候,如果JVM 启用了TLAB(默认是启用的, 可以通过 -XX:-UseTLAB 关闭),则会创建并初始化TLAB。同时,在GC 扫描对象发生之后,线程第一次尝试分配对象的时候,也会创建并初始化TLAB。
在TLAB已经满了或者接近于满了的时候,TLAB可能会被释放回Eden。GC扫描对象发生时,TLAB会被释放回Eden。TLAB 的生命周期期望只存在于一个GC 扫描周期内。在JVM中,一个 GC 扫描周期,就是一个epoch。那么,可以知道,TLAB 内分配内存一定是线性分配的。
TLAB的初始大小可由参数-XX:TLABSize指定,若指定了TLAB的值,TLAB初始大小就是TLABSize。否则,TLAB大小为分配线程的平均值。
源码地址:https://github.com/openjdk/jdk/blob/master/src/hotspot/share/gc/shared/threadLocalAllocBuffer.cpp
TLAB 的大小的最小值:通过MinTLABSize指定
TLAB 的大小的最大值:不同GC中有不同的最大值。例如G1 GC中,TLAB的最大值为大对象的大小,即是Region的一半;ZGC中的最大值为1/8的Region,在大部分情况下Shenandoah GC也是每个Region 大小的 8 分之一。对于其他的GC,则是int 数组的最大大小。
TLAB空间大小的动态调整:
默认情况下:
-XX:ResizeTLAB
resize开关是默认开启的,JVM可以对TLAB空间大小进行调整。
对象的慢分配
当TLAB内存充足时,分配新对象的方式称为快分配。当TLAB内存不足,分配新对象的方式称为“慢分配”。慢分配有两种处理方式:
1、当TLAB剩余内存空间小于TLAB最大浪费空间时,丢弃当前 TLAB 回归 Eden,线程获取新的 TLAB 分配对象。
2、当TLAB剩余内存空间大于TLAB最大浪费空间时,对象直接在Eden区分配内存。
TLAB最大浪费空间
最大浪费空间是一个动态值,TLAB最大浪费空间初始值=TLAB大小/TLABRefillWasteFraction。TLABRefillWasteFraction默认为64,所以TLAB最大浪费空间初始值为TLAB大小的1/64。伴随着每次慢分配,这个TLAB最大浪费空间会每次递增 TLABWasteIncrement 大小的空间。
参数名称 | 参数作用 |
---|---|
UseTLAB | 是否启用 TLAB,默认是启用的。 |
ResizeTLAB | TLAB 是否是自适应可变的,默认为是 |
TLABSize | 初始 TLAB 大小,单位是字节 。默认为0,0 就是不主动设置 TLAB 初始大小,而是通过 JVM 自己计算每一个线程的初始大小。例如:-XX:TLABSize=65536 |
MinTLABSize | 最小 TLAB 大小。单位是字节,默认2048。例如-XX:MinTLABSize=4096 |
TLABRefillWasteFraction | 在一次 TLAB 再填充(refill)发生的时候,最大的 TLAB 浪费。默认为64,和TLAB最大浪费空间有关。TLAB最大浪费空间= TLAB大小/TLABRefillWasteFraction |
TLABWasteIncrement | TLAB 慢分配时允许的 TLAB 浪费增量. |
参考:
https://blog.csdn.net/a1076067274/article/details/112969208
在对象中添加一个引用计数器,当有地方引用这个对象的时候,引用计数器的值就+1,当引用失效的时候,计数就减一
Java中一般不用:引用计数方法。
如何判断垃圾如何回收。
查看gc信息的方式
案例:
创建循环引用方式:
断掉右侧的先:
Jdk8采用的并不是引用计数法,而是默认是:parallel垃圾回收即。
在Java中,是通过可达性分析(Reachability Analysis)来判定对象是否存活的。该算法的基本思路就是通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象是不可用的。
如上图所示,object1~object4对GC Root都是可达的,说明不可被回收,object5和object6对GC Root节点不可达,说明其可以被回收。
在Java中,可作为GC Root的对象包括以下几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象
方法区中类静态属性所引用的对象
方法区中常量所引用的对象
本地方法栈中JNI(即一般说的Native方法)引用的对象
在堆里存放着几乎多有的java对象实例,垃圾搜集器在对堆进行回收之前,第一件事情就是确定这些对象之中哪些还“存活”着(即通过任何途径都无法使用的对象)。
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。
标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
1. 第一次标记并进行一次筛选。
筛选的条件是此对象是否有必要执行finalize()方法。
当对象没有覆盖finalize方法,或者finzlize方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,对象被回收。
2. 第二次标记
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为:F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象finalize()方法中执行缓慢,或者发生死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。
Finalize()方法是对象脱逃死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己----只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。
从可达性算法中可以看出,判断对象是否可达时,与“引用”有关。那么什么情况下可以说一个对象被引用,引用到底代表什么?
在JDK1.2之后,Java对引用的概念进行了扩充,可以将引用分为以下四类:
强引用(Strong Reference)
软引用(Soft Reference)
弱引用(Weak Reference)
虚引用(Phantom Reference)
这四种引用从上到下,依次减弱
强引用就是指在程序代码中普遍存在的,类似Object obj = new Object()这类似的引用,只要强引用在,垃圾搜集器永远不会搜集被引用的对象。也就是说,宁愿出现内存溢出,也不会回收这些对象。
软引用是用来描述一些有用但并不是必需的对象,在Java中用java.lang.ref.SoftReference类来表示。对于软引用关联着的对象,只有在内存不足的时候JVM才会回收该对象。因此,这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。
import java.lang.ref.SoftReference;
public class Main {
public static void main(String[] args) {
SoftReference<String> sr = new SoftReference<String>(new String("hello"));
System.out.println(sr.get());
}
}
弱引用也是用来描述非必需对象的,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。下面是使用示例:
import java.lang.ref.WeakReference;
public class Main {
public static void main(String[] args) {
WeakReference<String> sr = new WeakReference<String>(new String("hello"));
System.out.println(sr.get());
System.gc(); //通知JVM的gc进行垃圾回收
System.out.println(sr.get());
}
}
虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。在java中用java.lang.ref.PhantomReference类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。
要注意的是,虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之 关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
public class Main {
public static void main(String[] args) {
ReferenceQueue<String> queue = new ReferenceQueue<String>();
PhantomReference<String> pr = new PhantomReference<String>(new String("hello"), queue);
System.out.println(pr.get());
}
}
在SoftReference类中,有三个方法,两个构造方法和一个get方法(WekReference类似):
public class SoftReference<T> extends Reference<T> {
/**
* Timestamp clock, updated by the garbage collector
*/
static private long clock;
/**
* Timestamp updated by each invocation of the get method. The VM may use
* this field when selecting soft references to be cleared, but it is not
* required to do so.
*/
private long timestamp;
/**
* Creates a new soft reference that refers to the given object. The new
* reference is not registered with any queue.
*
* @param referent object the new soft reference will refer to
*/
public SoftReference(T referent) {
super(referent);
this.timestamp = clock;
}
/**
* Creates a new soft reference that refers to the given object and is
* registered with the given queue.
*
* @param referent object the new soft reference will refer to
* @param q the queue with which the reference is to be registered,
* or null if registration is not required
*
*/
public SoftReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
this.timestamp = clock;
}
/**
* Returns this reference object's referent. If this reference object has
* been cleared, either by the program or by the garbage collector, then
* this method returns null
.
*
* @return The object to which this reference refers, or
* null
if this reference object has been cleared
*/
public T get() {
T o = super.get();
if (o != null && this.timestamp != clock)
this.timestamp = clock;
return o;
}
}
get方法用来获取与软引用关联的对象的引用,如果该对象被回收了,则返回null。
在使用软引用和弱引用的时候,我们可以显示地通过System.gc()来通知JVM进行垃圾回收,但是要注意的是,虽然发出了通知,JVM不一定会立刻执行,也就是说这句是无法确保此时JVM一定会进行垃圾回收的。
虚引用中有一个构造函数,可以看出,其必须和一个引用队列一起存在。get()方法永远返回null,因为虚引用永远不可达。
public class PhantomReference<T> extends Reference<T> {
/**
* Returns this reference object's referent. Because the referent of a
* phantom reference is always inaccessible, this method always returns
* null
.
*
* @return null
*/
public T get() {
return null;
}
/**
* Creates a new phantom reference that refers to the given object and
* is registered with the given queue.
*
* It is possible to create a phantom reference with a null
* queue, but such a reference is completely useless: Its get
* method will always return null and, since it does not have a queue, it
* will never be enqueued.
*
* @param referent the object the new phantom reference will refer to
* @param q the queue with which the reference is to be registered,
* or null if registration is not required
*/
public PhantomReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
}
}