2-JVM-内存模型

本文详细探讨了Java内存区域,包括程序计数器、虚拟机栈、本地方法栈、堆、方法区(元空间)以及直接内存。讲解了对象创建的内存分配方式、并发线程安全问题的解决方案,如TLAB和CAS。此外,分析了对象内存布局,包括对象头、实例数据和对齐填充,以及指针压缩的作用。还介绍了几种垃圾收集器的工作原理,如安全点、安全区域、抢先式中断和主动式中断。最后,讨论了逃逸分析、标量替换和内存溢出的各种情况。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在这里插入图片描述
程序计数器:

记录的是正在执行的字节码的指令地址,如果执行的是native方法,计数器的值为空,此内存区域是是唯一一个在java虚拟机中没有规定任何oom情况的区域,为了程序切换后能够恢复到正确的执行,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,这类内存区域为“线程私有”的内存】

虚拟机栈(也会发生oom)

线程私有、局部变量表存放了编译期可知的各种基本数据类型、对象应用类型如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverFlorError;如果虚拟机栈可以动态扩展,如果扩展时无发申请足够的内存,就会抛出 OutOfMemoryError

本地方法栈

会抛出 StackOverflow 和 OutOfMemoryError

线程共享、java规范描述:所有的对象实例以及数组都要在堆上分配,单是随着JIT技术的发展和逃逸分析技术的逐渐成熟,栈上分配内存。标量替换 对象分配就不那么“绝对”了

方法区

线程共享 存放类信息、常量、静态变量、即时编译器编译后的代码 (他有一个别名Non-Heap非堆,目的是与java堆分开)
无法满足内存分配需求时 会抛出 OutOfMemoryError

运行时常量池

方法区的一部分,用于存放编译期生成的各种字面量和符号引用,这个部分内容将在类加载后进入方法区的运行时常量池中存放

直接内存
不是虚拟机运行是数据的一部分,但是被频繁使用,可能会导致OutOfMemoryError

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

在这里插入图片描述
对象的创建

内存分配方式:

假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump ThePointer)。但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定

  • 当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;
  • 而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上 [1] 就只能采用较为复杂的空闲列表来分配内存。

解决并发 线程安全问题:

  • 1.对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;

  • 2.另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local AllocationBuffer,TLAB)

对象内存布局

对象在堆内存中的存储布局可以划分为三个部分:

对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

对象头:
在这里插入图片描述

  • 第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark
    Word”
  • 另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例(并非必须)

如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据

对象大小可以用jol-core包查看
ClassLayout layout = ClassLayout.parseInstance(new Object());
 System.out.println(layout.toPrintable());

什么是java对象的指针压缩?

1.jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩
2.jvm配置参数:UseCompressedOops,compressed–压缩、oop(ordinary object pointer)–对象指针
3.启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops

为什么要进行指针压缩?

1.在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力
2.为了减少64位平台下内存的消耗,启用指针压缩功能
3.在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)
4.堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
5.堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好

对象访问方式
1.句柄 ,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
在这里插入图片描述
2.直接指针,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销

在这里插入图片描述
直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销(主要采用的这种)

安全点与安全区域

安全点就是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比如GC等,所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发。这些特定的安全点位置主要有以下几种:

  • 方法返回之前
  • 调用某个方法之后
  • 抛出异常的位置
  • 循环的末尾

如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来?

但是HotSpot虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用int类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(CountedLoop),相对应地,使用long或者范围更大的数据类型作为索引值的循环就被称为不可数循环(Uncounted Loop),将会被放置安全点 【由安全点导致长时间停顿】
参考:https://juejin.im/post/5d1b1fc46fb9a07ef7108d82

抢先式中断(Preemptive Suspension):

不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上(基本废弃)

主动式中断(Voluntary Suspension)

当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起

安全区域又是什么?

Safe Point 是对正在执行的线程设定的。如果一个线程处于 Sleep 或Blocked状态,它就不能响应 JVM的中断请求,再运行到 Safe Point 上。因此 JVM 引入了 Safe Region。Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。线程在进入 Safe Region的时候先标记自己已进入了 Safe Region,等到被唤醒时准备离开 Safe Region 时,先检查能否离开,如果 GC完成了,那么线程可以离开,否则它必须等待直到收到安全离开的信号为止。

JVM内存参数设置

Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里)【JDK1.8】:

java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar demo.jar

关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N,对于64位JVM来说,元空间的默认初始大小是21MB,默认的元空间的最大值是无限。MaxMetaspaceSize用于设置metaspace区域的最大值。一旦触及则Full GC将被触发并卸载没有用的类(类对应的类加载器不再存活),然后高水位线将会重置。新的高水位线的值取决于GC后释放的元空间。如果释放的空间少,这个高水位线则上升。如果释放空间过多,则高水位线下降。由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。

-Xss设置越小count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多
在这里插入图片描述
对象分代

  • 新生代 是用来存放新生的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发MinorGC 进行垃圾回收。新生代又分为
    Eden 区、ServivorFrom、ServivorTo 三个区。
  • 老年代 主要存放应用程序中生命周期长的内存对象
  • 永久代 指内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被放入永久区域,GC不会在主程序运行期对永久区域进行清理【元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入native memory, 字符串池和类的静态变量放入 java 堆中】

Jdk1.6及之前: 有永久代, 常量池在方法区
Jdk1.7: 有永久代,但已经逐步“去永久代”,常量池在堆
Jdk1.8及之后: 无永久代,常量池在元空间

逃逸分析
JVM的运行模式有三种:

  • 解释模式(Interpreted Mode):只使用解释器(-Xint 强制JVM使用解释模式),执行一行JVM字节码就编译一行为机器码
  • 编译模式(Compiled Mode):只使用编译器(-Xcomp
    JVM使用编译模式),先将所有JVM字节码一次编译为机器码,然后一次性执行所有机器码
  • 混合模式(Mixed Mode):依然使用解释模式执行代码,但是对于一些 “热点” 代码采用编译模式执行,JVM一般采用混合模式执行代码
  • AOT(Ahead-of-Time Compilation)
    将javac编译器编译后的字节码直接编译成机器代码,避免了JIT预热等各方面的开销(jdk9)

解释模式启动快,对于只需要执行部分代码,并且大多数代码只会执行一次的情况比较适合;编译模式启动慢,但是后期执行速度快,而且比较占用内存,因为机器码的数量至少是JVM字节码的十倍以上,这种模式适合代码可能会被反复执行的场景;混合模式是JVM默认采用的执行代码方式,一开始还是解释执行,但是对于少部分 “热点 ”代码会采用编译模式执行,这些热点代码对应的机器码会被缓存起来,下次再执行无需再编译,这就是我们常见的JIT(Just In Time Compiler)即时编译技术。在即时编译过程中JVM可能会对我们的代码做一些优化,比如对象逃逸分析等

对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

public void test2() {
   User user = new User();
   user.setId(1);
   user.setName("zhuge");
   //TODO 保存到数据库
}

test2方法中的user对象我们可以确定当方法结束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配的栈内存里,让其在方法结束时跟随栈内存一起被回收掉。
JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

public class MemoryEscape {

    /**
     * 进行两种测试
     * 关闭逃逸分析,同时调大堆空间,避免堆内GC的发生,如果有GC信息将会被打印出来
     * VM运行参数:-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
     
     * 开启逃逸分析
     * VM运行参数:-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
     *
     * 执行main方法后
     * jps 查看进程
     * jmap -histo 进程ID
     *
     */
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        //查看执行时间
        System.out.println("cost-time " + (end - start) + " ms");
        try {
            Thread.sleep(100000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }
    private static void alloc() {
        //Jit对编译时会对代码进行 逃逸分析
        //并不是所有对象存放在堆区,有的一部分存在线程栈空间
        TulingStudent student = new TulingStudent();
    }
    static class TulingStudent {
        private String name;
        private int age;
    }
}

标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启。
标量与聚合量:标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。

结论:栈上分配依赖于逃逸分析和标量替换

内存溢出
java堆溢出:堆用于存储对象实例,不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象

/**
 * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {
    static class OOMObject {
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

}
虚拟机栈和本地方法栈溢出
1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
2)如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出
OutOfMemoryError异常【HotSpot虚拟机的选择是不支持扩展

/**
 * VM Args:-Xss128k
 */
public class JavaVMStackSOF_1 {
    private int stackLength = 1;
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    public static void main(String[] args) {
        JavaVMStackSOF_1 oom = new JavaVMStackSOF_1();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

方法区和运行时常量池溢出(常量池是方法区的一部分)

public class RuntimeConstantPoolOOM_1 {

    public static void main(String[] args) {
        // 使用Set保持着常量池引用,避免Full GC回收常量池行为
        Set<String> set = new HashSet<String>();
        // 在short范围内足以让6MB的PermSize产生OOM了
        short i = 0;
        while (true) {
            set.add(String.valueOf(i++).intern());
        }
    }
}

直接内存溢出

import sun.misc.Unsafe;
import java.lang.reflect.Field;
/**
 * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
 */
public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值