jvm内存模型全面解析

文章详细分析了JVM内存结构,包括栈、堆、方法区等,以及对象在内存中的分配和引用类型的区别。讨论了垃圾回收的策略,如新生代和老年代的处理,介绍了各种垃圾回收器的工作原理,如Serial、ParallelScavenge和CMS等,并提到了JVM调优的技巧和参数设置。
摘要由CSDN通过智能技术生成

jvm 内存模型分析

  • jvm 的样子
    在这里插入图片描述

  • 栈:用来存储函数当前运行过程中的一些临时变量。

    • (栈、本地方法栈、程序计数器)这三个是线程私有的。
    • 线程私有的意思是:每一个线程开启都会在内存中声明一份对应的空间。有多少个线程,可能就会有多少个栈区。
  • 堆:全局共享的,用来存储对象。

  • 方法区:存储一些元数据信息,jdk1.7之前又叫做永久代。但是jdk8之后,把它改名为元数据空间。主要存储一些静态的方法或者变量。类加载器 classLoader 。等等一些这样的全局数据信息。

值类型内存过程的调用

在这里插入图片描述

  • 以 func1() 函数为例:
    • main 调用 func1 会在栈中声明一个空间,由于形参 a=10,所以栈里面有个 a=10 的临时变量,接着b=10,然后 打印 a+b,给a赋值 a=11,此时会把 func1 开辟出来的空间中的 a 赋值为 11,然后方法体结束。根据栈(先入后出)的原则:会先删除 b,再删除 a。最有将 func1 声明出来的这个区域全部删除掉。

在这里插入图片描述

  • 为什么 main 函数打印出来的结果是 10,而不是 20?
    • 因为在 调用 func1 的使用,他实际上是新开辟了一个空间,在里面声明了一个 a=10,func1 运行完毕之后,就把空间给删除掉了,并不会影响到 main 函数里面 a 的结果。
引用类型内存过程的调用

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

  • 分析说明:

    • 由于 main 方法他是 static 的,所以会在方法区中去声明。如果假设 class Main 里面还有这行代码:

      public static Integer i = 10 那么他就会在方法区中去声明一个 i=10的位置。方法区是全局共享的。

    • 然后继续分析 mian 函数里面的 func1 函数。当执行到 func1 的时候,会在栈里面开辟一个空间,里面从上往下声明 a=10,b=10。

    • 执行到 Persion p = new Persion() 时,就不一样了。由于 new 是用来给对象开辟内存的关键字。

      • 所以此时发生的事情是:new Persion 会在 堆 中创建一个 Persion 对象。
      • 对象里面包含两部分:1、对象的内容(有默认初始值),2、对象在堆中的地址。
      • 栈中的 p 实际上存储的是 persion 对象在堆中的地址。
      • 然后执行到 p.id=1 ,由于 id 是 int 类型的,所以会通过地址找到堆中的 psersion.id 给他赋值为1;
      • 接着 p.name= new String("liming") 由于 name 是 string 类型的(string 是个引用类型),所以也会在堆中会声明一个 String 对象,也是分为两部分:1、在堆中的地址,2、内容 “liming”(是个 char 数组也会创建对象,但是数组中的 char 元素他是值类型的,直接存储到内存)。所以 persion.name 的值实际上存储的也是 String 对象的地址。
    • 最后,打印 a+b, 函数结束,会把 func1 声明出来的栈的空间删除。但是 堆中的对象不会被回收,因为你不能保证其他线程有没有引用该对象。所以交由 GC 来回收。

  • 警告(博主之后发现的错误):

    • p.name=“liming” 这种形式声明的字符串比较特殊,会以字符串常量的形式存在方法区的常量池中,所以改成p.name = new String(“liming”)才对!!
    • 然后是char数组也是对象类型,数组中的char元素才是值类型,回头看一遍发现了两处错误,实在抱歉!!
JVM GC 垃圾回收

在这里插入图片描述

  • 就上图过程分析(由上到下,由左到右):
    • 新生的对象(做好逃逸分析,标量替换)如果能分配到栈上最好,直接分配到栈里面,(走Y):方法执行完毕之后直接删除,效率超高。
    • 如果在栈上分配不了,(走N):再判断这个对象是不是特别大(如何判断,就说有个参数是用来管理的就行),(走Y):如果特别大,直接进入老年代(老年代采用“标记-整理算法”,发生在老年代的 GC 称为“Full GC”)。交给 GC 来进行回收。
    • (走N):否则,进行线程本地分配(TLAB)【了解就好,就是用来提高效率的】,进入线程本地分配缓存区,然后(走 Y / N ) 最后都是进入 Eden 区。
    • Eden 区的对象经过垃圾回收,如果回收了就回收了,没有回收掉进入 S1 ,再经过垃圾回收,年龄够了(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置),进入老年代,年龄不够进入 S2 。然后循环往复(采用“复制算法”,优点不会产生内存碎片)。

名词解释:

  • Eden : Survivor (幸存者)。默认比例为8:1。

  • 逃逸分析。(https://zhuanlan.zhihu.com/p/59215831):逃逸分析只是一种分析,是为其他优化手段提供分析支撑,它本身并不做优化。逃逸分析是分析方法体的局部变量的作用范围是否会逃出方法体。
    逃逸分析包括

  • 标量替换。:标量就是不可再分解的量,JAVA的基本数据类型就是标量,反之就是聚合量,比如对象。如果逃逸分析确定对象不会被外部使用,并且可以再分。jvm不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。
    -XX:+EliminateAllocations:标量替换(默认打开)

垃圾回收器

在这里插入图片描述

在这里插入图片描述

  • 年轻代采用的是 copy 算法。

    • 新出生的对象会在 Eden 区,每次回收的时候,会把 Eden 和其中一个 S 区还存活的对象 copy 到另外一个 S 区中。如果标记的次数达到 6 次或者 15 次(有一个系统的默认值)。那么该对象就会进入老年代。 【eden区内存不足时触发】
      *在这里插入图片描述
  • 老年代是在空间即将耗尽时触发。

  • Minor GC与Full GC分别在什么时候发生?

    新生代内存不够用时候发生MGC也叫YGC,JVM内存不够的时候发生FGC

  • Serial(单线程的垃圾回收器 ,工作在年轻代
    • 在这里插入图片描述
* 解释说明:Serial 收集器

  Serial,是单线程执行垃圾回收的。当需要执行垃圾回收时,程序会暂停一切手上的工作,然后单线程执行垃圾回收。

  因为新生代的特点是对象存活率低,所以收集算法用的是复制算法,把新生代存活对象复制到老年代,复制的内容不多,性能较好。


   

  单线程地`好处`就是减少上下文切换,减少系统资源的开销。

  但这种方式的缺点也很明显,在GC的过程中,会暂停程序的执行。若GC不是频繁发生,这或许是一个不错的选择,否则将会影响程序的执行性能。 

  对于新生代来说,区域比较小,停顿时间短,所以比较使用。
  • Serial Old (单线程的垃圾回收器 ,工作在老年代
    • 在这里插入图片描述
* 解释说明:Serial Old 收集器

  老年代的收集器,与Serial一样是单线程,不同的是算法用的是标记-整理(Mark-Compact)。


   

  因为老年代里面对象的存活率高,如果依旧是用复制算法,需要复制的内容较多,性能较差。并且在极端情况下,当存活为100%时,没有办法用复制算法。所以需要用Mark-Compact,以有效地避免这些问题。
  • Paraller Scavenge (多线程的垃圾回收器 ,工作在年轻代)
    • 在这里插入图片描述
* 解释说明:Parallel Scavenge收集器

  新生代的收集器,同样用的是复制算法,也是并行多线程收集。与ParNew最大的不同,它关注的是垃圾回收的吞吐量。

  这里的吞吐量指的是 总时间与垃圾回收时间的比例。这个比例越高,证明垃圾回收占整个程序运行的比例越小。

  Parallel Scavenge收集器提供两个参数控制垃圾回收的执行:

  - **-XX:MaxGCPauseMillis**,最大垃圾回收停顿时间。这个参数的原理是空间换时间,收集器会控制新生代的区域大小,从而尽可能保证回收少于这个最大停顿时间。简单的说就是回收的区域越小,那么耗费的时间也越小。
    所以这个参数并不是设置得越小越好。设太小的话,新生代空间会太小,从而更频繁的触发GC。
  - **-XX:GCTimeRatio**,垃圾回收时间与总时间占比。这个是吞吐量的倒数,原理和MaxGCPauseMillis相同。

  因为Parallel Scavenge收集器关注的是`吞吐量`,所以当设置好以上参数的时候,同时不想设置各个区域大小(新生代,老年代等)。可以开启**-XX:UseAdaptiveSizePolicy**参数,让JVM监控收集的性能,动态调整这些区域大小参数。
  • Paraller old (多线程的垃圾回收器 ,工作在老年代)
    • 在这里插入图片描述
* 解释说明: Parallel Old收集器

  老年代的收集器,是Parallel Scavenge老年代的版本。其中的算法替换成Mark-Compact(标记-整理算法)。
  • ParNew 收集器 (多线程的垃圾回收器 ,工作在年轻代)
    • ParNew 同样用于新生代,是 Serial 的多线程版本,并且在参数、算法(同样是复制算法)上也完全和 Serial 相同。
    • Par 是 Parallel 的缩写,但它的并行仅仅指的是收集多线程并行,并不是收集和原程序可以并行进行。ParNew 也是需要暂停程序一切的工作,然后多线程执行垃圾回收。
  • 因为是多线程执行,所以在多CPU下,ParNew效果通常会比Serial好。但如果是单CPU则会因为线程的切换,性能反而更差。

  • CMS收集器
    • CMS,Concurrent Mark Sweep,同样是老年代的收集器。它关注的是垃圾回收最短的停顿时间(低停顿),在老年代并不频繁GC的场景下,是比较适用的。
    • 命名中用的是 concurrent(百度翻译:同时发生的)而不是 parallel(百度翻译:平行),说明这个收集器是有与工作执行并发的能力的。MS则说明算法用的是Mark Sweep(标记清除)算法。
    • 来看看具体地工作原理。CMS整个过程比之前的收集器要复杂,整个过程分为四步:
      • 初始标记(initial mark),单线程执行,需要“Stop The World”,但仅仅把GC Roots的直接关联可达的对象给标记一下,由于直接关联对象比较小,所以这里的速度非常快。
      • 并发标记(concurrent mark),对于初始标记过程所标记的初始标记对象,进行并发追踪标记,此时其他线程仍可以继续工作。此处时间较长,但不停顿。
      • 重新标记(remark),在并发标记的过程中,由于可能还会产生新的垃圾,所以此时需要重新标记新产生的垃圾。此处执行并行标记,与用户线程不并发,所以依然是“Stop The World”,时间比初始时间要长一点。
      • 并发清除(concurrent sweep),并发清除之前所标记的垃圾。其他用户线程仍可以工作,不需要停顿。
* 由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是`低停顿`的。
* 由于CMS以上特性,缺点也是比较明显的,
  * Mark Sweep算法会导致内存碎片比较多
  * CMS的并发能力依赖于CPU资源,所以在CPU数少和CPU资源紧张的情况下,性能较差
  * 并发清除阶段,用户线程依然在运行,所以依然会产生新的垃圾,此阶段的垃圾并不会再本次GC中回收,而放到下次。所以GC不能等待内存耗尽的时候才进行GC,这样的话会导致并发清除的时候,用户线程可以了利用的空间不足。所以这里会浪费一些内存空间给用户线程预留。
  • 有人会觉得既然Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact呢?
  • 答案其实很简答,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact更适合“Stop the World”这种场景下使用。

引用

线程本地分配缓冲区

最近,我一直在研究遭受严重性能问题的Java应用程序。 在许多问题中,真正引起我注意的一个问题是新对象的分配速率相对较低(应用程序分配了大量的相当大的对象)。 后来发现,原因是在TLAB之外发生了大量分配。

什么是TLAB?

在Java中,新对象在Eden中分配。 这是线程之间共享的内存空间。 如果考虑到多个线程可以同时分配新对象,那么显然需要某种同步机制。 怎么解决呢? 分配队列? 某种互斥锁? 即使这些是不错的解决方案,也有更好的解决方案。 这就是TLAB发挥作用的地方。 TLAB代表线程本地分配缓冲区,它是Eden内部的一个专为线程分配的区域。 换句话说,只有一个线程可以在该区域分配新对象。 每个线程都有自己的TLAB。 因此,只要在TLAB中分配对象,就不需要任何类型的同步。 在TLAB内部进行分配很简单
指针缓冲 (这就是为什么有时将其称为指针缓冲分配)
–因此将使用下一个空闲内存地址。

TLAB变得满满的

可以想象,TLAB不是无限的,在某些时候它开始变满。 如果线程需要分配一个不适合当前TLAB的新对象(因为它几乎已满),则会发生两件事:

  • 线程获取新的TLAB
  • 该对象在TLAB之外分配

JVM根据几个参数决定将要发生的情况。 如果选择了第一个选项,则线程的当前TLAB将“退休”,并且分配将在新的TLAB中完成。 在第二种情况下,分配是在Eden的共享区域中完成的,这就是为什么需要某种同步的原因。 通常,同步是有代价的。

物体太大

默认情况下,将针对每个线程分别动态调整TLAB的大小。 根据Eden的大小,线程数及其分配率重新计算TLAB的大小。 更改它们可能会影响TLAB的规模-但是,由于分配率通常会有所不同,因此没有简单的公式可以解决。 当线程需要分配一个永远无法放入TLAB的大对象(例如,大数组)时,它将在Eden的共享区域中分配,这又意味着同步。 这正是我的应用程序中正在发生的事情。 由于某些对象太大,因此它们从未在TLAB中分配。
在TLAB之外分配一些对象不一定是一件坏事–这是在次要GC之前发生的典型情况。 问题是,与TLAB内部相比,TLAB外部存在大量分配。 在这种情况下,有两个可用选项:

  • 使物体变小
  • 尝试调整TLAB尺寸

就我而言,手动调整TLAB大小不是最佳选择。 众所周知,只有少数对象类型在TLAB之外分配。 通常,修复代码是最好的选择。 在我将对象显着减小之后,它们已装入TLAB,并且TLAB内部分配给TLAB外部分配的比率恢复正常。

jvm 调优
  • 压缩对象头指针。(让对象变得小一些)

  • 什么是调优?

    • 1、根据需求进行 JVM 规划和预调优
    • 2、优化运行 JVM 运行环境(慢、卡顿)
    • 3、解决 JVM 运行过程中出现的各种问题(OOM)
  • jmap -histo 6583 | head -10 动态观察

    • 在这里插入图片描述

    • instances :实例化出来的对象数量。class name:对应的类。

      如果发现 某个 class 对应的 instances 数量在不断的增加,gc 之后没有减少,说明这个地方出现问题了。

  • jmap -dump:format=b,file=xxoo.prof 6583 静态观察。把整个堆内存复制一份,生成一个文件,放在 file 里面。可以把文件拿走进行分析。

  • 在这里插入图片描述

24.你知道哪些JVM性能调优

  • 设定堆内存大小

-Xmx:堆内存最大限制。

  • 设定新生代大小。 新生代不宜太小,否则会有大量对象涌入老年代

-XX:NewSize:新生代大小

-XX:NewRatio 新生代和老生代占比

-XX:SurvivorRatio:伊甸园空间和幸存者空间的占比

  • 设定垃圾回收器 年轻代用 -XX:+UseParNewGC 年老代用-XX:+UseConcMarkSweepGC

里面。可以把文件拿走进行分析。

  • [外链图片转存中…(img-7StuPbg5-1676337651610)]

24.你知道哪些JVM性能调优

  • 设定堆内存大小

-Xmx:堆内存最大限制。

  • 设定新生代大小。 新生代不宜太小,否则会有大量对象涌入老年代

-XX:NewSize:新生代大小

-XX:NewRatio 新生代和老生代占比

-XX:SurvivorRatio:伊甸园空间和幸存者空间的占比

  • 设定垃圾回收器 年轻代用 -XX:+UseParNewGC 年老代用-XX:+UseConcMarkSweepGC
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TimBL

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值