JVM学习总结--内存结构

332 阅读10分钟

1、JVM内存结构(JDK7以前)

1-1、程序计数器

1-2、Java虚拟机栈(Java Virtual Machine Stack)

1-3、本地方法栈(Native Method Stack)

1-4、Java堆

1-5、方法区

1-6、运行时的常量池

用于存放编译器生成的各种字面量和符号引用

1-7、直接内存(Direct Memory)

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。

JDK1.4的NIO类,引入基于通道(Channel)与缓冲区(Buffer)的I/O方式,可以使用Native函数库直接分配【堆外内存】;

动态扩展是出现OOME异常

2、虚拟机内存大小配置参数

2-1、以Java8的内存结构举例

Java虚拟机的内存大小 = 堆 + 方法区(元数据区) + 线程堆栈

Java内存 = -Xmx + -XX:MaxMetaspaceSize + 所有运行线程的堆栈内存之和

【堆(Heap)】内存由两部分组成:新生代和老年代;

新生代分为三个部分,一个Eden区和两个Survior区;

2-2、JVM内存大小设置参数

2-3、Java方法中的局部变量是否线程安全?

刚好在和之前学习的多线程并发结合思考一下

Java方法中的变量是线程安全。

原因:局部变量的生命周期只是在方法执行期间,而方法 在 Java虚拟机栈 中执行,每个线程会单独创建一个Java虚拟机栈执行方法,所以是线程私有的,那方法中的局部变量自然也是线程私有的,也是线程安全的。

2-4、关于Java对象的创建

一个对象在Java内存应该如何创建?如果是JVM的设计者,该如何解决这个问题,毕竟内存大小是有限的,如果内存位置分配不合理,可能对后续的垃圾回收也有影响。这里是从《深入理解Java虚拟机》上看到的。

Q: 对象内存空间如何划分?

  • 指针碰撞(Bump the Pointer):【假设Java堆中的内存是(绝对规整)的】,用过的内存都放在一边,空闲的内存放在另一边,中间放着一个【指针】作为【分界点】的指示器,分配内存就仅仅是把【指针】向【空闲区域】挪动一段【与对象大小相等】的距离;带【Compact】过程的垃圾收集器,如 Serial 或者 ParNew 采用【指针碰撞】,因为这两个收集器使用的是复制算法,如果知道需要复制算法的内存区域,应该可以加快GC收集的效率(仅个人看法);

  • 空闲列表(Free List): Java堆中的内存是**【不规整】**的,【已使用】和【空闲】内存相互交错,必须维护一个【列表记录】哪些内存是可用的;CMS这种基于【Mark-Sweep】算法的收集器,采用【空闲列表】,因为CMS使用的是【标记-清除】算法,这个算法本身就会产生内存碎片,所以比较适合这种内存划分。

Q: 如何保证【并发】情况下【创建对象】的【线程安全】?

除了内存区域的问题,还要考虑对象创建在虚拟机中是非常频繁的行为,在【并发情况】下是线程不安全的,同一个内存区域,就像共享资源一样,是多线程共享的,有可能出现两个线程同时看到一块空闲的区域,同时去申请内存,这刚好也是并发情况下的【可见】性问题,Java解决的方法是:

  • 对分配内存空间的动作进行【同步处理】,虚拟机采用【CAS】配上【失败重试】的方式保证更新操作原子性;
  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):把内存分配的动作按照【线程】划分在不同的空间中,每个线程在【Java堆】中【预先分配】一小块内存;

虚拟机是否使用【TLAB】,可以通过【-XX:+/-UseTLAB】参数设定;

2-4、对象的内存布局

对象在内存中存储的布局可以分为3块区域:

  1. 对象头(Header)
  2. 实例数据(Instance Data);
  3. 对齐填充(Padding

对象头又包括两个部分:

  • 存储对象自身的【运行时数据】,这部分官方称它为【Mark Word】,运行时的数据包括:哈希码(HashCode)、GC分代年龄、【锁状态标志】、线程持有的锁、【偏向】线程ID、偏向时的时间戳等,数据在长度32位和64位虚拟机中分别为32big和64bit,具体可以参考并发部分【自旋锁、偏向锁、轻量级锁和重量级锁】的说明;
  • 类型指针,用来指向对象的【类元数据】,虚拟机通过指针确定这个对象是哪个类的实例;

2-5、对象的访问定位

对象创建好后,接下来就是要考虑,如何定位到创建好的对象?如何知道创建的对象是什么类型?

在理解【引用】和【实例】的概念时,可以想象自己的大脑和真实物体的关系;

大脑的空间是有限的,也不可能把真实物体放到人脑内,所以大脑中记忆的就是物体存放的位置,要想查找某个东西,在大脑中找到物品存放的位置,就可以找到了。

同样,对象的引用和对象本身也是这样的关系

对象的引用可以放在内存较小的区域,就是虚拟机栈,通过引用找到对象在内存中的地址,自然也找到对象实例本身;因为比起实例,引用需要的内存大小还是很少的。

Java需要通过【栈上】的【reference数据】来操作堆上的具体对象。

对象访问方式:

  • 使用句柄;
  • 直接指针;

1、使用【句柄】访问

Java堆将会划分出两部分,一块内存来作为句柄池,另一块存放对象的实例池,reference中的存储就是对象的【句柄地址】,在句柄中包含了对象【实例数据】与【类型数据】各自的具体地址;类型数据存放在方法区(元数据区);

优点:reference存储的是稳定的句柄指针,对象实例移动时,只需要修改句柄中实例数据指针,而reference本身不需要修改;

2、使用直接指针

Java栈中的reference直接指向对象实例,而对象实例中保存【对象类型数据】,Sun Hotspot用这种方式在【对象头(Header)】中存放【对象类型数据】

优点:速度快,节省一次指针定位的时间开销;

2-6、哪些区域会内存溢出?产生的原因是什么?如何解决?

1、堆内存溢出

异常打印时显示【Java heap space】;

java.lang.OutOfMemoryError: Java heap space

通过参数【-XX:+HeapDumpOnOutOfMemoryError】可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便分析;

产生原因:堆中大量实例没有回收;

解决过程

先通过内存分析工具对Dump出的堆转储快照进行分析,确认内存中的对象是否是必要的。分清【内存泄漏(Memory Leak)】 还是【内存溢出(Memory Overflow)】

2、Java7以前【方法区】内存溢出

异常打印时显示【PerGen space】;

java.lang.OutOfMemoryError: PerGen space

产生原因:方法区存放大量Class相关信息;

3、Java8【元数据区】内存溢出

异常打印时显示【Metaspace】;

java.lang.OutOfMemoryError: Metaspace

产生原因:方法区存放大量Class相关信息;

4、虚拟机栈和本地方法栈内存溢出

虚拟机栈和本地方法栈会产生两种异常:

  • 如果线程请求的【栈深度】大于虚拟机允许的【最大深度】,将抛出【StackOverflowError】异常;比如【递归调用】循环次数太多,或者 一个栈的容量(通过-Xss配置)太小;
  • 如果虚拟机在【扩展】栈时【无法申请到足够的内存空间】,则抛出【OutOfMemoryError】异常;

可以通过【java -XX:+PrintFlagsFinal -version | grep ThreadStackSize】查看【栈深度】

虚拟机栈 + 本地方法栈内存 = Java最大内存(MaxHeapFreeRatio) - 【JVM heap 最大值 + 方法区(Metaspace)】;

因为 虚拟机栈 和 本地方法栈 生命周期 与 线程一致,方法结束或线程结束,内存自然回收;

2-7、关于逃逸分析

刚才提到对象创建,是存放在堆中,如果所有的对象都在堆中创建,但是垃圾回收是非常耗时的。而如果对象只在方法中创建,并且不会传入其他方法,或者与其他线程共享,在栈中分配内存会是一个很不错的主意。

这就引出关于【逃逸分析(Escape Analysis)】的优化技术。

【逃逸分析】的基本行为就是分析对象动态作用域,分为两个类型:

  • 方法逃逸:当一个对象在方法中被定义后,可能被外部方法引用,例如作为调用参数传递到其他方法,如下列代码:

    public void concatString() { String str = new String(); cancat(str);
    }

    private String concat(String str) { return str + ", Hello World!"; }

  • 线程逃逸:在方法中创建的对象,被外部线程访问到,例如赋值给类变量或可以在其他线程中访问的实例变量,举例,单例模式;

2-7-1、逃逸分析理论

如果能证明一个对象不会逃逸到方法或线程外,则可能为这个变量进行高效优化。

  • 栈上分配(Stack Allocation)

问题:在Java堆上分配对象的内存空间,当持有对象引用时,就可以访问对象,并且对象对于所有线程都是共享和可见的。垃圾收集器负责回收那些不占使用对象占用内存区域,但回收比较耗时。

优化:如果确定一个对象不会逃逸方法外,让这个对象在栈上分配内存,这样对象占用的内存空间就可以随栈帧出栈而销毁。

观点:在一般应用中,不会逃逸的局部对象所占比例很大,如果使用栈上分配,那大量的对象就会随着方法的结束而自动销毁,垃圾收集系统压力会小很多。

个人思考:栈空间有限,如果创建对象都分配在栈上,可能会导致StackOverflowError,所以如果有逃逸分析优化,在方法中尽量使用原始数据类型代替包装类型,可以减少栈空间使用;

  • 同步消除(Synchronization Elimination)

线程同步本身是一个相对耗时过程,如果逃逸分析能确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定不会由竞争,对这个变量实时的同步措施可以消除。

  • 标量替换(Scalar Replacement)

关于标量(Scalar)和聚合量(Aggregate)

一个数据已无法再分解成更小的数据来表示,比如原始数据类型(int, long 或者 reference类型),这个数据可以称为标量;

如果一个数据可以继续分解,就称为聚合量(Aggregate);

所谓标量替换,是指 把一个Java对象【拆】散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问。比如用【包装类型】定义的变量,对象拆分后,除了可以让对象的成员变量在栈上分配和读写外,还可以为后续进一步的优化手段创建条件。

2-7-2、逃逸分析缺点

Java虽然在JDK1.6开始就实现逃逸分析,但这项优化技术还未成熟,仍有很大改进余地。

不成熟的主要原因:

不能保证逃逸分析的性能收益必定高于它的消耗。

2-7-3、JVM关于逃逸分析的参数

-XX: +DoEscapeAnalysis,手动开启逃逸分析;

-XX: +PrintEscapeAnalysis,查看分析结果;

-XX: +EliminateAllocation,开启标量替换;

-XX: +EliminateLocks,开启同步消除;

-XX: +PrintEliminateAllocations,查看标量替换情况;