jvm:内存模型、内存分配及GC垃圾回收机制

3,052 阅读17分钟

1.为什么要学习jvm?

  • 一开始接触jvm,是因为最近面试频繁被问到,于是拜读了《深入理解java虚拟机:jvm高级特性与最佳实践》,这是一本非常好的书,推荐。
  • 看了里面的几章之后,对java执行一段代码的整个过程有了一定的理解;比如看了内存模型之后,发现了stackoverflow(栈溢出)和OutofMemory(内存不足)这几种类型的错误主要引起的原因是什么?不同类型下的解决方案是什么?
  • 写这个博客主要是想把自己最近看的jvm所有知识点能够串起来,以便后面学习更好理解。
  • 什么是jvm:虚拟出来的计算机,是jre的一部分,使用jvm是为了支持与操作系统无关,实现跨平台,jvm内部体系结构主要分为三个部分:类加载器子系统,运行时数据区和执行引擎。

  • java中跨平台与C++跨平台的区别:java中跨平台的意义在于jvm(运行区间)的跨平台;而c++跨平台在于jdk(编译期间)跨平台;

2.主要内容:

2.1 java代码执行

1.java源文件-》编译器-》字节码文件;
2. 字节码文件-》jvm-》机器码(见上图)

3.每一个平台的解释器是不同的,但是实现的虚拟机是相同的,这是java能够跨平台的原因。

4.当一个程序从开始运行,虚拟机就开始实例化,多个程序启动的话就会存在多个虚拟机实例。程序退出或者关闭,虚拟机实例会消失,多个虚拟机实例之间的数据不能共享。

2.2 java内存区域

1.运行时数据区

  • 其中线程私有数据区域的生命周期和线程相同,每个线程与操作系统的本地线程直接映射,这部分的内存区域跟着本地线程创建销毁。
  • 线程共享区域随虚拟机的启动而创建,关闭而销毁。
  • 除pc之外,别的区域都有可能造成OOM

2.直接内存

  • 直接内存频繁被使用,可能造成OOM
  • NIO中引入了基于CHANEL与buffer的IO方式,可以使用native函数库直接分配堆外内存。使用DirectByteBuffer对象作为这块内存的引用进行操作。避免了java堆和native堆中来回复制数据,因此在一些场景中可以显著提高性能。

3.运行时数据区主要内容

  • pc:指向当前线程所执行的字节码的行号
  • 虚拟机栈:主要描述方法执行,每个方法在执行的时候创建一个栈帧,其中栈帧中存储了局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用到执行结束,对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
  • 本地方法区:虚拟机栈执行java方法;本地方法栈执行native方法。比如java中调用C或C++中的方法,在这里执行。有的虚拟机会将虚拟机栈与本地方法栈合二为一。

  • :创建的对象与数组都保存在java堆内存中,因为是线程共享,所以与方法区两个是垃圾收集的区域,java堆中从GC的角度细分为:新生区与老年区。
  • 方法区:用于存储被jvm加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。**使用java堆的永久代实现方法区,GC收集的永久代区域。**永久代的内存回收主要目标是针对常量池的回收和类型的卸载。运行时常量池是方法区的一部分,class文件除了有类的版本,字段,方法,接口等描述信息外,还有一项是常量池,主要存放编译期间生成的各种字面量和符号引用,这部分内容在类加载后存放到方法区的运行时常量池中。

2.3 jvm是如何分配内存的?

1.堆上分配

存放对象实例与数组

2.栈上分配

存放局部变量

Java 栈内存空间用于执行线程, 栈内存始终遵循LIFO(Last-in-first-out) 顺序, 每当一个方法被执行, 会在栈内存中创建一个新的栈帧 用于保存在函数中定义的基本数据类型变量以及对象的引用变量;
当方法结束时, 这个栈帧 改变它的状态为未使用并且可用于执行下一个方法

实例理解堆栈内存
代码:


public class HeapStackTestMemory {

    public static void main(String[] args) { //Line 1

        int i = 1; //Line 2

        Object obj = new Object(); //Line 3

        HeapStackTestMemory mem = new HeapStackTestMemory(); //Line 4

        mem.foo(obj);  //Line 5

    }  //Line 9

    private void foo(Object param) {  //Line 6

        String str = param.toString();  //Line 7

        System.out.println(str);

    } //Line 8

}

程序执行步骤:

  • 一旦我们开始运行程序, 它会把所有的运行时类加载到堆内存空间, 在 Line 1 行找到main() 方法, Java Runtime 创建由main() 方法线程使用的栈内存空间
  • 在第二行 我们创建了原始数据类型的局部变量, 所以它将被存储在main() 方法的栈内存空间
  • 在第3行我们创建了一个Object 类型的对象, 所以它被创建在Heap 堆内存空间中 并且 Stack 栈内存空间包含对它的引用, 当我们在第4行中创建Memory 对象时, 会发生类似的过程
  • 现在我们在第5行调用foo() 方法, 此时会在stack 栈创建一个block 供foo() 方法使用
  • Java 是通过值传递, 在第6行, 会在foo() 栈中创建一个对Object 对象的新的引用
  • 在第7行 , 一个string 类型的对象被创建, 此时 会在foo() 栈内存中创建它的一个引用 str
  • foo() 方法在第8行执行完毕, 此时, 程序会释放stack 栈内存中为foo() 方法分配的栈内存空间
  • 在第9行, main() 方法执行完毕, 为main()方法创建的堆栈内存被销毁, 此时 这个java 程序结束运行, Java Runtime 会释放所有的内存

基于上述的说明, 可以很容易的总结出堆栈内存的以下差异

1,生命周期: 堆内存属于java 应用程序所使用, 生命周期与jvm一致;栈内存属于线程所私有的, 它的生命周期与线程相同
2, 引用:不论何时创建一个对象, 它总是存储在堆内存空间 并且栈内存空间包含对它的引用 . 栈内存空间只包含方法原始数据类型局部变量以及堆空间中对象的引用变量
3, 在堆中的对象可以全局访问, 栈内存空间属于线程所私有
4, jvm 栈内存结构管理较为简单, 遵循LIFO 的原则, 堆空间内存管理较为复杂 , 细分为:新生代和老年代 etc..
5, 栈内存生命周期短暂, 而堆内存伴随整个用用程序的生命周期
6, 二者抛出异常的方式: 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常, 堆内存抛出OutOfMemoryError异常

3.TLAB分配

Thread Local Allocation Buffer 线程本地分配缓存区
如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

4.对象内存如何分配

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在子常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

1.指针碰撞: 假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
2.空闲列表: 如果Java堆中内存并不是规整的,已使用的内存和空闲的内存相互交错,虚拟机就必须维护一个列表,记录上那些内存块是可用的,在分配的时候从列表中找到一个足够大的空间划分给对象实例,并更新列表上的记录。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞;而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

参考:juejin.cn/post/684490… juejin.cn/post/684490…

2.4 哪些内存需要回收?

不再使用的对象需要进行回收,不使用的类也有可能回收。

2.5 什么情况下回收(两种方法)?

如何判断一个对象不再使用?

1.引用计数法

定义:给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减一;任何时刻计数器为0的对象就是不会被使用的对象。

2.GC roots

通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径被称为引用链,当一个对象到“GC Roots”没有任何引用链相连的时候,就证明此对象是不可用的。

可以作为GC root对象包括

  • 虚拟机栈(栈帧中的本地变量表)中的引用对象。
  • 方法区中的静态属性或常量(final)引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

2.6 jvm如何保证正确的回收?

分代收集:根据对象存活的不同年龄划分不同的域。

新生代:复制算法


老年代:标记复制算法:


分代收集算法将heap区域划分为新生代和老年代,新生代的空间比老年代的空间要小。新生代又分为了Eden和两个survivor空间,它们的比例为8:1:1。对象被创建时,内存的分配是在新生代的Eden区发生的,大对象直接在老年代分配内存,IBM的研究表明,Eden区98%的对象都是很快消亡的。

  为了提高gc效率,分代收集算法中新生代和老年代的gc是分开的,新生代发生的gc动作叫做minor gc 或 young gc,老年代发生的叫做major gc 或 full gc。

  minor gc 的触发条件:当创建新对象时Eden区剩余空间小于对象的内存大小时发生minor gc;

  major gc 触发条件:

  1、显式调用System.gc()方法;

  2、老年代空间不足;

  3、方法区空间不足;

  4、从新生代进入老年代的空间大于老年代空闲空间;

  Eden区对象的特点是生命周期短,存活率低,因此Eden区使用了复制算法来回收对象,上面也提到复制算法的特点是在存活率较低的情况下效率会高很多,因为需要复制的对象少。与一般的复制算法不同的是,一般的复制算法每次只能使用一半的空间,另一半则浪费掉了,Eden区的回收算法也叫做"停止-复制"算法,当Eden区空间已满时,触发Minor GC,清理掉无用的对象,然后将存活的对象复制到survivor1区(此时survivor0有存活对象,survivor1为空的),清理完成后survivor0为空白空间,survivor1有存活对象,然后将survivor0和survivor1空间的角色对象,下次触发Minor gc时重复上述过程。如果survivor1区剩余空间小于复制对象所需空间时,将对象分配到老年代中。每发生一次Minor gc时,存活下来的对象的年龄则会加1,达到一定的年龄后(默认为15)该对象就会进入到老年代中。

  老年代的对象基本是经过多次Minor gc后存活下来的,因此他们都是比较稳定的,存活率高,如果还是用复制算法显然是行不通的。所以老年代使用“标记-整理”算法来回收对象的,从而提高老年代回收效率。

  总的来说,分代收集算法并不是一种具体的算法,而是根据每个年龄代的特点,多种算法结合使用来提高垃圾回收效率。

2.7 垃圾收集算法

1.标记-清除算法

 标记清除算法分为“标记”和“清除”两个阶段,首先先标记出那些对象需要被回收,在标记完成后会对这些被标记了的对象进行回收  

缺点:碎片多;虚拟机在给内存较大对象分配空间时,有可能找不到足够大的连续空间存放,从而引发垃圾回收动作。实际上有大量空闲空间,只是不连续;

2.复制算法

复制算法是将内存分为两块大小一样的区域,每次是使用其中的一块。当这块内存块用完了,就将这块内存中还存活的对象复制到另一块内存中,然后清空这块内存。

现在商用的jvm中都采用了这种算法来回收新生代,因为新生代的对象基本上都是朝生夕死的,存活下来的对象约占10%左右,所以需要复制的对象比较少,采用这种算法效率比较高。
hotspot版本的虚拟机将堆(heap)内存分为了新生代和老年代,其中新生代又分为内存较大的Eden区和两个较小的survivor区。当进行内存回收时,将eden区和survivor区的还存活的对象一次性地复制到另一个survivor空间上,最后将eden区和刚才使用过的survivor空间清理掉。hotspot虚拟机默认eden和survivor空间的大小比例为8:1,也就是每次新生代中可用内存空间为整个新生代空间的90%(80%+10%),只会浪费掉10%的空间。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当survivor空间不够用时,需要依赖于其他内存(这里指的是老年代)进行分配的担保。

3.标记-整理算法

复制算法在对象存活率较高的情况下就要进行较多的对象复制操作,效率将会变低。更关键的是,如果你不需要浪费50%的空间,就需要有额外的空间进行分配担保,用以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种办法。

  根据老年代的特点,有人提出了标记-整理的算法,标记过程仍然与标记-清楚算法一样,但后续步骤不是直接将可回收对象清理掉,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,算法示意图如下:   

2.8 垃圾收集器

1.serial垃圾收集器

新生代
单线程
复制算法 client模式下默认的新生代垃圾收集器

2.Parnew垃圾收集器

serial+多线程

server模式下默认的新生代垃圾收集器

3.parallel Scavenge收集器

多线程复制算法
高效
重点关注程序达到一个可控制的吞吐量 : 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)

自适应调节策略,是PS收集器与ParNew收集器的一个重要区别

4.serial old收集器

单线程
标记整理算法
运行在client默认的java虚拟机老年代拉进收集器
在server模式下,两个用途:
1.jdk15之前版本与新生代PS收集器搭配使用;
2.作为老年代中使用CMS收集器的后备垃圾收集方案。

  • 新生代Serial与老年代Serial Old搭配:

  • 新生代PS与老年代serial old搭配:

5.parallel old收集器

PS老年版本
多线程标记-整理算法
jdk1.6之前,PS只能跟serial old搭配收集;
jdk1.6之后,PS与老年代的parallel Old版本搭配:

6.CMS收集器:多线程标记清除算法

老年代的垃圾收集算法,主要目标是获取最短垃圾回收停顿时间,与其他老年代标记整理不同的是,这里使用的是多线程标记-清除算法,主要分为4个阶段:

  • 初始标记:标记GC roots能直接关联的对象,速度快,仍然需要暂停所有工作线程。
  • 并发标记:进行GC roots跟踪的过程,跟用户线程一起工作,不需要暂停工作线程。
  • 重新标记:

  • 并发清除

7.G1收集器

2.9 java类加载机制

类加载:object o =new object() 主要包括三个阶段:通过不同的类加载器加载,当类被加载之后,进行验证,只要包括:文件格式验证,元数据验证,字节码验证,符号引用验证;准备阶段是为类的静态变量分配内存;

2.10 执行引擎

字节码是如何被虚拟机执行从而完成指定功能?

3.如何监控和优化GC

1.基于jdk命令行工具监控

jvm参数
jstat查看虚拟机统计信息
jmap+MAT分析内存溢出
jstack分析死循环与死锁

2.基于JVisualVM可视化监控

启动:

参考:www.mamicode.com/info-detail…

3.GC日志打印查看

一般内存调整策略:

例子:

  public static void main(String[] args) {
     //new thread01().start();
      System.out.println(Runtime.getRuntime().maxMemory()/(double)1024/1024);//打印虚拟机最大内存,兆
      System.out.println(Runtime.getRuntime().totalMemory()/(double)1024/1024);//打印总内存
  }

以上面这段代码为例子:
可以看到结果分别为1797与123兆;

设置jvm参数使total与虚拟机最大内存相等:-Xmx2G -Xms2G

其他基本设置如下:

本机例子打印GC日志:
-Xmx2G
-Xms2G
-XX:+PrintGCDetails
-XX:+PrintHeapAtGC

4.学习jvm,给我带来了什么?

1.了解了java数据区域的整体内存模型,在声明局部变量以及new对象实例的内存分配。
2.方法运行时,java栈中栈帧的一系列变化。
3.堆栈在jvm中的区别
4.分代垃圾回收机制,什么时候回收?回收什么垃圾?有哪些垃圾回收算法?不同版本下使用不同的垃圾收集器。
5.反编译后(javap -c)的字节码指令大概能够看一点,了解其整体在jvm中是如何一步一步运行的。
6.监控和GC参数日志查看。