总结《深入理解java虚拟机》
一、对象
(文 中讨论的对象限于普通Java对象,不包括数组和Class对象等)
1.对象的创建过程?
①类加载
当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到 一个类的符号引用,
检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那 必须先执行相应的类加载过程
②分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成 后便可完全确定(为对象分配空间的任务实际上便等同于把一块确定 大小的内存块从Java堆中划分出来)。
采用哪种内存分配方式,由使用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定(下一篇)
-
指针碰撞(Bump The Pointer)
这种分配方式需要Java堆中内存是绝对规整的,所有被使用过的内存都被放在一 边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那 个指针向空闲空间方向挪动一段与对象大小相等的距离。
当使用Serial、ParNew等带压缩 整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效
-
空闲列表(Free List)
若Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那 就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分 配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
而当使用CMS这种基于清除 (Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
③初始化
内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果 使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进。(这步操作保证了对象的实例字段 在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。)
接下来,Java虚拟机还要对对象头进行必要的设置,如这个对象是哪个类的实例、如何才能找到 类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才 计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟 机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
此时,所有的字段都 为默认的零值,new指令之后会接着执行<init> ()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
Java编译器会在遇到new关键字的地方同时生成两条字节码指令:
new指令用于类的加载和内存的分配以及初始化对象属性为0值;
invokespecial指令用于调用构造函数也就是<init>()方法,要先调用父类的<init>()方法
此时对象已经创建好了,如果是Object instance = new Object();还需要设置instance指向刚分配的内存地址(引用具体怎样定位对象见后面),那么就存在一个问题:设置引用和invokespecial指令不存在依赖关系,也就是说会有指令重排的可能,多线程情况下,就会存在线程安全问题。
2.对象的组成?
Java中对象的组成
1)首先,我们先了解一下Java中对象的组成:
在JVM中,对象在内存中分为三块区域:对象头、实例数据、对齐填充
-
对象头
对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)
-
Mark Word(标记字段)
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“MarkWord”。
-
Class Pointer(类型指针)
对象指向它的类元数据(方法区)的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
-
数组长度
如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度.
-
-
实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来,但是静态属性不算在内的。
-
对齐填充
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
一个空对象的大小是8个字节,因为自动填充会帮我们补齐
2)对象大小的计算:
①在32位系统下,存放Class指针的空间大小是4字节,MarkWord是4字节,对象头为8字节。 ②在64位系统下,存放Class指针的空间大小是8字节,MarkWord是8字节,对象头为16字节。
③在32位系统下,存放数组长度的空间大小是4字节;在64位系统下,存放数组长度的空间大小是8字节
④64位开启指针压缩的情况下,存放Class指针的空间大小是4字节,MarkWord是8字节,对象头为12字节。
3)32位 JVM 的 Mark Word 的默认存储结构如下:
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下 4 种数据:
在 64 位虚拟机下,Mark Word 是 64bit 大小的,其存储结构如表:
对象头的最后两位存储了锁的标志位,随着锁级别的不同,对象头里会存储不同的内容。偏向锁存储的是当前占用此对象的线程ID;而轻量级则存储指向线程栈中锁记录的指针
3.对象的访问定位
创建对象后,Java程序会通过栈上的reference数据来操作堆上的具 体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义 这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实 现而定的,主流的访问方式主要有使用句柄和直接指针两种:
句柄访问
Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就 是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,
优势:是reference中存储的是稳定句柄地 址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference本身不需要被修改。
直接指针
Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关 信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问 的开销,
优势:速度更快,它节省了一次指针定位的时间开销,由于对象访 问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,
HotSpot虚拟机主要采用第二种方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的 话也会有一次额外的转发)
二、内存溢出OOM
OOM属于错误,是java虚拟机本身的错误与程序代码无关,不可处理。
常见的oom异常
1.StackOverflowError栈溢出异常,由方法的堆积导致,栈很小,一般512k-1024k
由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置 本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。关于虚拟 机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:
1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
2)如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出 OutOfMemoryError异常。
《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机 的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法 容纳新的栈帧而导致StackOverflowError异常。
演示:
StackOverflowError
public class StackOverflowErrorDemo {
public static void main(String[] args) {
myMethod(); // 方法互相引用
}
private static void myMethod() {
myMethod();
}
}
OutOfMemoryError :unable to create new native thread
public class OutOfMemoryDemo {
public static void main(String[] args) {
for (;;){ // 不断创建线程
new Thread(() -> {
}).start();
}
}
}
注意:
通过上面不断建立线程的方式,在HotSpot上也是可以产生内存溢出异常,但是这样产生的内存溢出异常可能存在两种原因:
①主要取决于操作系统本身的内存使用状态。
因为操作系统会限制每个进程中能创建的最大线程数量(比如,linux系统的默认1024个线程),超出这个数量会报oom异常
解决:修改操作系统配置,增加默认线程数量(以Linux为例)
vi /etc/sysctl.conf // 末尾追加下面的三行
vm.max_map_count = 1000000
kernel.pid_max = 1000000 // 修改最大进程数
kernel.threads-max = 1000000 // 修改最大线程数
保存后,执行sysctl -p使其生效
vi /etc/security/limits.d/90-nproc.conf // 找到这一行,
* soft nproc 1024 // 修改最后的数字为102400
退出登录工具(SecureCRT),重新连接 执行ulimit -u(显示用户可以使用的资源限制) 命令查看是否为修改后的值 注意:此处的数字值不是随意改大都能生效,如果超出系统能设置的最大值时,系统自动以系统最大值为有效。 附:系统最大值计算方法:
default_nproc = total_memory/128K total_memory获取:cat /proc/meminfo |grep MemTotal,单位KB
$ cat /proc/meminfo |grep MemTotal
MemTotal: 16425852 KB
$ echo "16425852 / 128"| bc
128326
②还可能是因为线程申请内存(为虚拟机栈申请内存)无法获得满足造成的
操作系统分配给每个进程的内存是有限制的,譬如32位Windows的单个进程 最大内存限制为2GB。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值, 那剩余的内存即为2GB(操作系统限制)减去最大堆容量,再减去最大方法区容量,由于程序计数器 消耗内存很小,可以忽略掉,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存 就由虚拟机栈和本地方法栈来分配了。因此为每个线程分配到的栈内存越大,可以建立的线程数量自 然就越少,建立线程时就越容易把剩下的内存耗尽。
解决: 减小虚拟机栈和本地方法栈所占的内存大小
通过-Xss参数设置
但是线程栈的大小是个双刃剑,如果设置过小,可能会出现栈溢出,特别是在该线程内有递归、大的循环时出现溢出的可能性更大,如果该值设置过大,就有影响到创建线程的数量,如果是多线程的应用,就会出现内存溢出的错误.
2.OutOfMemoryError:Java heap space 堆内存溢出异常,可能创建了过多的对象
Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径 来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会 产生内存溢出异常。
演示:
OutOfMemoryError: Java heap space
/**
* @author smileha
* @create 2022-03-26 11:35
* @description
vm option -Xms5m -Xmx5m -XX:+HeapDumpOnOutOfMemoryError
通过参数-XX:+HeapDumpOnOutOf-MemoryError可以让虚拟机
在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析
*/
public class HeapSpaceDemo2 {
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
while (true){
list.add(new Object());
}
}
}
转储快照
解决:
常规的处理方法是首先通过内存映像分析工具(如JProfile)对Dump出来的堆转储快照进行分析。
①第一步首先应确认内存中导致OOM的对象是否是必 要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
内存泄漏(memory leak): 指一个不再被程序使用的对象或变量还在占用着内存空间无法被回收。
内存溢出: 指程序在申请内存时,没有足够的内存空间供其使用。(就是内存不够用了)
②如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎 样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息 以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内 存泄漏的代码的具体位置
③如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机 的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查 是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运 行期的内存消耗。
3.OutOfMemoryError: GC overhead limit exceeded,GC回收时间过长会抛出此异常
过长的定义是,超过98%的时间用来做GC并且回收了不到2%的堆内存,连续多次GC都只回收了不到2%的极端情况下才会抛出。
假如不抛出错误会发生什么情况?那就是GC清理的这么点内存很快会被再次填满,迫使GC再次执行,这样就形成了恶性循环,cpu的使用率一直是100%,而GC却没有任何成果。
演示:
OutOfMemoryError: GC overhead limit exceeded
/**
* @author smileha
* @create 2022-03-06 15:45
* @description
vm option -Xms10m -Xmx10m -XX:+PrintGCDetails
*/
public class GCHeadLimitDemo {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>();
int i = 0;
while (true){
list.add(String.valueOf(i++));
}
}
}
跟据打印的垃圾回收情况可以看出,多次GC没有任何效果
注意:有的时候我们可能会发现同样的操作报的却是OutOfMemoryError: Java heap space,那是因为GC回收的次数太少,没有达到98%,堆空间就已经满了;我们尝试增大堆空间,就会测出此异常,因为GC overhead limit exceeded的核心就是GC频繁,却没有回收成果
解决:
①-XX:-UseGCOverheadLimit使用次参数来关闭GC Overhead limit exceeded异常的抛出,但是仍会因为堆空间被占满了抛出 Java heap space,治标不治本(极不推荐)
②从根本上解决问题,排查堆转储快照,找到具体是哪些对象,占用了大量堆内存,又因为存在怎样的引用关系没法被回收掉。(与堆内存溢出一样进行排查)
4.OutOfMemoryError:Direct buffer memory,直接内存不足时,抛出此异常
写NIO程序经常使用ByteBuffer来读取或者写入数据,这是-种基于通道(Channel)与缓冲区(Buffer)的I/0方式,它可以使用Native函数库直接分配堆外内存(直接内存),然后通过一个 存储在Java堆里面的Direc tByteBuffer对象作为这块内存的引用进行操作。这样能在一 些场景中显奢提高性能,因为避免了在Java堆和Native堆中来回复制数据。
直接内存的大小可以通过-XX:MaxDirectMemorySize=xx设置,默认为机器内存的1/4(则默认与Java堆最大值(由-Xmx指定)一致)。
ByteBuffer. allocate(capability)第一种方式是分配JVM堆内存, 属于GC管辖范围,由于需要拷贝所以速度相对较慢 ByteBuffer. allocteDirect(capability)第二种方式是分配0S 本地内存,不属于GC 管辖范围,由于不需要内存拷贝所以速度相对较快。
演示:
OutOfMemoryError:Direct buffer memory
/**
* @author smileha
* @create 2022-03-06 16:27
* @description
* vm option -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m 设置最大直接内存5m
*/
public class DirectBufferMemoryDemo {
public static void main(String[] args) {
// 一般为机器内存的1/4
System.out.println("JVM所能够访问的最大堆外内存:"
+sun.misc.VM.maxDirectMemory()/(double)1024/1024 + "MB");
// 分配直接内存---6m
ByteBuffer.allocateDirect(6 * 1024 * 1024);
}
}
由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常 情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了 DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。
5.OutOfMemoryError:Metaspace,元空间内存溢出,
jdk8以后用元空间替换了永久代,来作为对方法区的实现。方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比 较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况(如:CGLib直接操作字节码运行时生成了大量的动态类---动态代理)。
元空间中主要存放:类元信息、运行时常量池、即时编译后的代码。
我们可以通过参数:
·-XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存 大小。
·-XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集 进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放 了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值
·-XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可 减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最 大的元空间剩余容量的百分比。(元空间剩余容量/元空间总容量)
演示:
/**
* @author smileha
* @create 2022-03-06 18:29
* @description cglib动态代理,通过修改.class字节码生成子类
* vm option -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
*/
public class MetaspaceDemo {
static class OOM{}
public static void main(String[] args) {
int i = 0;
try {
while (true){ // 循环动态生成代理类对象
i++;
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOM.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invoke(o,args);
}
});
enhancer.create();
}
}catch (Throwable e){
e.printStackTrace();
}finally {
System.out.println(i + "次出现了异常!!!");
}
}
}
下一篇,垃圾回收.......