JVM知识汇总

1. JVM内存区域

1.1. JVM内存组成及其作用

如下图所示:

image-20220221232111788

JVM总共包含两个子系统和两个组件。分别是:

  • 两个子系统:类加载器和执行引擎;
  • 两个子组件:运行时数据区和本地接口;

它们各自有不同的作用:

  • 类加载器:根据给定的全限定类型装在class文件到运行数据域中的方法域中;
  • 执行引擎:执行classes文件中的指令;
  • 本地接口:与本地方法库交互,是其它编程语言交互的接口;
  • 运行时数据区域:也就是常说的JVM内存;

1.2 JVM运行机制

  1. Java文件源码编译成.class字节码文件,加载至类加载器中;
  2. 类加载器再将.class文件加载到JVM中;

通俗来说:类加载器将类的.class文件读入内存,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构;

1.3 JVM运行时数据

  • 程序计数器:当前线程执行字节码的行号指示器,字节码解析的工作是通过改变计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能;
  • Java虚拟机栈:存储局部变量表、操作数栈、动态链接以及方法出口等;
  • 本地方法栈:作用与虚拟机栈一样,只是针对的是虚拟机调用Native方法服务的;
  • Java堆:虚拟机中内存最大的一块,被所有线程共享,几乎所有对象的实例都在这里分配内存;
  • 方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据;

1.4 深拷贝浅拷贝

  • 浅拷贝:增加一个指针指向已存在的内存地址;
  • 深拷贝:增加一个指针并且申请一个新内存,使这个增加的指针指向新的内存;

使用深拷贝时,释放内存不会因为出现浅拷贝释放同一个内存的错误

  • 浅复制:仅仅指向被复制的内存地址,如果原地址变化,浅复制出来的对象也会改变;
  • 深复制:在计算机内存中开辟一块新的内存地址用于存放复制的对象;

1.5 堆栈的区别

堆 Heap

  1. 内存空间不连续,内存分配在运行期确认,大小不固定;
  2. GC有不同的算法回收(标记-清除,标记-复制,标记,压缩,分代算法)
  3. 存放的是对象(包括静态对象)的实例和数组,因此更关注数据的存储;
  4. 对于整个应用程序可见,并且共享;

栈 Stack

  1. 先进后出原则,物理地址分配连续,性能快;
  2. 内存分配是在编译期确认,大小固定;
  3. 局部变量、操作数栈,返回结果,静态变量等;
  4. 线程私有,生命周期和线程相同;

2. Hotspot虚拟机对象探索

2.1 对象创建

记住一点:只有clone和反序列化创建对象没调用构造函数,其他的创建对象方式都调用了构造函数。

对象创建的流程如下:

  • 检查常量池中是否有已经加载的类
    • 没有:执行对应的类加载器;
    • 有:从内存中直接加载,不需要调用对应的类加载器;
  • 当前内存区域是否规整?
    • 是:使用“指针碰撞”来分配内存
    • 不是:从空闲列表中分配内存
  • 考虑并发问题,比如CAS同步处理以及本地线程分配缓冲(TLAB)
  • 设置对象的必要信息(元信息、哈希码);
  • 执行<init>方法,完成对象创建;

2.2 对象内存分配

对象分配内存(在堆中分配)也有两种方式:

  • 指针碰撞:规整内存的前提下,将用过的内存放一边,空闲放另一边。分配时将位于中间的指针指示器向空闲的内存移动一段与对象大小同等的距离,从而完成分配工作;
  • 空闲列表:不规整内存的前提下,需要有JVM维护一个列表记录哪些内存空间课哟就那个,这样在分配的时候可以从列表中查询到足够大的内存分配给对象;

2.3 并发安全问题处理

处理并发安全问题也有如下两个方式:

  • 采用CAS(什么是CAS)+失败重试来保障更新操作的原子性;
  • 按照线程划分在不同空间进行内存分配,即在每个线程在Java堆中预先分配一小块内存,成为TLAB。哪个线程要分配内存,就在哪个线程的TLAB上分配。只有TLAB用完并分配新的TLAB才需要同步锁。借助-XX:+/-UserTLAB参数设定JVM是否使用TLAB;

2.4 对象的访问定位

Java需要通过JVM栈上的引用访问堆中的具体对象,对象访问直接取决于JVM虚拟机的实现。主流的方式有句柄直接指针两种方式:

  • 句柄:指向指针的指针,维护对象的指针。不直接指向对象,而是指向对象的地址(句柄不发生变化,指向固定的内存地址),再由对象的指针指向对象的真是内存地址;
  • 指针:指向对象,代表一个对象在内存中的起始地址;

句柄访问

句柄池是Java堆中的一块内存,引用存储对象的句柄地址,句柄包含了对象实例数据对象类型数据各自的具体地址信息,具体构造如下:

image-20220222115625143

优点:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改;

直接指针

使用直接指针访问,引用中存储的是对象地址,在Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息;

image-20220222120220653

优势:速度快,节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的成本。HotSpot虚拟机使用的就是这种方式。

3. 内存溢出异常

3.1 什么是内存泄露,Java存在内存泄露

内存泄露指的是不再被使用的对象或者变量一直占据在内存中,理论上说Java有GC垃圾回收机制,不再被使用的对象,会被GC自动回收,自动从内存中清除;

但即便如此,Java依然存在内存泄露的情况:即长生命周期的对象持有短声明周期对象的引用就可能发生内存泄露,尽管短生命周期对象已经不再需要,但由于长生命周期的对象一直持有短生命周期的引用导致它不能被GC回收。这样就会产生JVM内存泄露;

4. GC垃圾收集器

4.1简述Java垃圾回收机制

在Java中程序不需要显示释放一个对象的内存,一切都是交给JVM虚拟机完成。在JVM中存在一个垃圾回收线程为低优先级,正常情况下不会执行,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行。扫描那些没有被任何对象引用的对象,并将其添加到要回收的集合中,完成回收。

4.2 什么是GC?为什么要GC

GC是(Garbage Collection)的意思,内存处理是编程人员容易出问题的地方,忘记或错误的内存回收会导致程序或系统不稳定甚至崩溃。Java提供GC功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的。

需要注意的是:和C++不同,Java没有提供释放已分配内存的显式操作;

4.3 垃圾回收的优点和原理

原理

对于GC来说,当程序员创建对象时,GC就开始监控对象的地址、大小以及使用情况。

通常来说,GC采用有向图的方式记录和管理堆(Heap)中的所有对象,通过这种方式确定哪些对象是“可达的”;哪些对象是“不可达的”,当GC确定一些对象“不可达”时,GC就有责任回收这些内存空间;

程序员可以显式调用System.gc()来通知GC运行,但是Java语言不能保证GC一定会执行。

优点

可以看出,垃圾回收机制有效地防止了内存泄露,可以有效地使用可使用的内存。并且作为一个单独的低级别线程运行,在不可预知的情况下对内存堆中已经死亡或者很长时间没有使用的对象进行清除和回收。

垃圾回收的方式

有下面三种回收方式:

  • 复制垃圾回收
  • 标记垃圾回收
  • 增量垃圾回收

4.4 Java中的引用类型

  • 强引用:发生gc时不会被回收;(最经典的:去看ArrayList的clear方法)
    • 被GC回收的时间:从来不会被回收
    • 用途:对象的一般状态
    • 生存时间:JVM停止运行时终止
  • 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收;
    • 被GC回收的时间:内存不足时
    • 用途:对象缓存
    • 生存时间:内存不足时终止
  • 弱引用:有用但不是必须的对象,在下次GC时会被回收;
    • 被GC回收的时间:正常垃圾回收
    • 用途:对象缓存
    • 生存时间:垃圾回收后终止
  • 虚引用:(幽灵引用/幻引用):无法通过虚拟引用获得对象,用PhantomReference实现幻引用,虚引用的用途是在GC时返回一个通知;
    • 被GC回收的时间:正常垃圾回收时
    • 用途:跟踪对象的垃圾回收
    • 生存时间:垃圾回收后终止

4.5 JVM对象回收

判断对象能否被回收

有两种方法:

  • 引用计数器法:每个对象创建一个引用计数,有对象引用计数器+1,引用释放计数器-1,计数器为0代表没有引用。缺点就是当出现循环引用,该对象无法被回收;
  • 可达性分析法:从GC Roots开始向下搜索,搜索所走过的路径成为引用链,当一个对象到GC Roots没有任何引用链相连时,证明此对象是可以被回收的;

对象什么时候可以被垃圾回收

当对象对当前使用这个对象的应用程序变得不可触及时,这个对象就可以被回收;

需要注意的是:通常垃圾回收不会发生在永久代,如果永久代满了或超过临界值会触发Full GC。因此正确的永久代大小对避免Full GC是非常至关重要的。

4.6 JVM垃圾清除算法

标记——清除算法

标记无用对象,然后清除回收;

优点:实现简单,不需要对象进行移动;

缺点:效率不高,无法清除垃圾碎片;

复制算法

按照容量划分两个大小相等的内存区域,一块用完后将活着的对象复制到另一块上,然后再把已使用的内存空间一次性清理;

优点:按顺序分配内存即可,实现简单,运行高效,不用考虑内存碎片;

缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制;

标记——整理算法

标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存;

优点:解决了标记——清理算法存在的内存碎片问题;

缺点:仍需要进行局部对象移动,一定程度上降低了效率;

分代算法

根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代采用复制算法,老年代采用标记——整理算法;

image-20220222141359995

4.7 垃圾回收器的工作原理

有7种作用不同分代的收集器,如下图:

image-20220222141536146

回收新生代的收集器:

  • Serial
  • ParNew
  • Parallel Scavenge

回收老年带的收集器:

  • Serial Old
  • Parallel Old
  • CMS

回收整个Java堆的收集器:G1

关系

  • Serial(复制算法):新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
  • ParNew(复制算法):新生代并行收集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;
  • Parallel Scavenge(复制算法):新生代并行收集器,高吞吐量,高效利用CPU,适合后台应用等对交互响应要求不高的场景;
  • Serial Old(标记——整理):老年代单线程收集,Serial收集器的老年代版本;
  • Parallel Old(标记——整理):老年代并行收集器,吞吐量优先;Parallel Scavenge收集器的老年代版本;
  • CMS(标记——清除算法):老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间;
  • G1(标记——整理算法):Java堆并行收集器,G1收集器是JDK1.7提供的新收集器,此外G1回收的范围是整个Java堆(包括新生代、老年代),前6种收集器回收的范围仅限于新生代和老年代;

CMS垃圾回收器

英文全称Concurrent Mark-Sweep简称,以牺牲吞吐量为代价获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上非常适合。在JVM参数上用-XX:+UseConcMarkSweepGC来指定使用CMS垃圾回收器。

由于使用“标记——清除”算法来实现,因此在gc过程中容易产生大量内存碎片,当剩余内存不能满足程序运行要求,系统会出现Concurrent Mode Failure,临时CMS会采用Serial Old回收器进行垃圾清除,此时性能将会被降低;

垃圾回收器的工作原理

分代回收器有两个分区:新生代和老年代,新生代默认空间占比总空间的1/3,老生代的默认占比是2/3;

分区如下:

  • 新生代:Eden,To Survivor,From Survivor。默认占比是8:1:1,执行流程如下:
    • 将Eden + From Survivor存活的对象放入To Survivor区;
    • 清空Eden和From Survivor分区;
    • 交换From Survivor 和 To Survivor分区

每次在From Survivor到To Survivor移动时都存活的对象,年龄+1;当年龄达到15升级为老年代,大对象也会直接进入老年代;

  • 老年代当空间占用达到某个值后就会触发Full GC,一般通过标记——整理算法执行

  • 以此循环往复就构成了整个分代垃圾回收的整体执行流程;

5. 内存分配策略

5.1 内存分配以及回收策略

自动内存管理,最终要解决的问题也是内存分配和内存回收,这里谈谈内存分配:

  • 通常在Java堆上分配(随着JVM优化技术,某些场景下也在栈上分配)
  • 分配的区域有下面两种:
    • 未开启TLAB,在新生代的Eden区分配,少数情况直接会在老年代分配;
    • 开启TLAB,按照线程优先在TLAB上分配;

5.2 对象在内存区域的分配

普世规则

对象优先在Eden区分配

多数情况下,对象都是在新生代的Eden区分配,当Eden区没有足够的空间,虚拟机就会触发一次Minor GC。如果Minor GC后还是没有足够的空间,则启用分配担保机制在老年代分配;

  • Minor GC:发生在新生代的GC,Java对象大多都是朝生夕死,所有Minor GC非常频繁,一般回收速度也非常快;
  • Major GC / Full GC:发生在老年代GC,出现Major GC通常会伴随至少一次Minor GC,Major GC的速度会比Minor GC慢10倍以上(知道为什么嘛?因为老年代的GC不经常发生)

大对象直接进老年代

大对象指的是需要大量连续内存空间的对象,频繁出现大量对象是致命的,会导致内存还有不少空间的情况下提前触发GC以获取足够的连续空间来安置新对象;

由于新生代使用的是复制算法回收垃圾,假如大对象一开始就在新生代分配就会导致Eden区和两个Survivor区之间发生大量内存复制。所以大对象直接进老年代;

长期存活对象将进入老年代

由于JVM采用分代收集来管理内存,那么在内存回收时必须判断哪些对象放在新生代,哪些对象放在老年代。

虚拟机给每个对象定义了一个对象年龄计数器:

  • 如果在Eden区出生,并且被Survivor容纳,年龄对象为1;
  • 对象每在Survivor区逃过一次Minor GC,年龄就+1;
  • 当年龄达到15,就会晋升到老年代;

6. 虚拟机类加载机制

6.1 Java类加载机制

虚拟机把类的描述从Class文件加载到内存,并对数据进行校验,解析和初始化。最终形成可以被虚拟机直接使用的java类型;

6.2 JVM加载Class文件的原理机制

Java中的所有类,都需要类加载器装载到JVM中才能运行,类加载器本身也是一个类,而它的工作就是把class文件从硬盘读到内存中。写程序的时候,几乎不需要关心类加载,因为整个过程都是隐式装载的,除非有特殊用法,例如反射;

6.3 类加载器过程

类装载的方式

有下面两种:

  • 隐式装载:程序在运行过程中碰到new等方式生成的对象,隐式调用类装载器加载对应的类到jvm中;
  • 显式装载:借助Class.forName()等方法显式加载需要的类;

Java类的加载是动态的,为了节省内存开销,除了将程序运行的基础类完全加载到jvm中,剩下的其他类则在需要的时候再加载;

类加载器

定义:实现通过类的权限定名获取该类的二进制字节流的代码块叫做类加载器;

主要有以下4种类加载器:

  • 启动类加载器(Bootstrap ClassLoader):用来加载Java核心类库,无法被Java程序直接引用;
  • 扩展类加载器(Extensions ClassLoader):用来加载Java扩展库,Java虚拟机的实现会提供一个扩展库目录,该类加载器在此目录里面查找并加载Java类;
  • 系统类加载器(System ClassLoader):一般来说,Java应用的类都是由它完成加载的。可以通过ClassLoader.getSystemClassLoader()来获取;
  • 用户自定义类加载器,通过继承java.lang.ClassLoader类实现;

类装载的执行过程

  1. 加载:根据查找路径找到对应的class文件然后导入;
  2. 验证:检查加载的class文件的正确性;
  3. 准备:给类中的静态变量分配内存空间;
  4. 解析:虚拟机将常量池中的符号引用替换成直接引用。符号引用就理解为一个标识,而在直接引用中指向内存中的地址;
  5. 初始化:对静态变量和静态代码块执行初始化工作;

6.4 双亲委派模型

对于任何一个类,都需要由加载它的类加载器和这个类本身一同确立在JVM中的唯一性,每一个类加载器,都有一个独立的类名称空间,类加载器就是根据指定全限定名称将class文件加载到JVM内存,然后再转化为class对象。

image-20220222181549279

  • 启动类加载器(Bootstrap ClassLoader):虚拟机自身的一部分,用来加载JAVA_HOME/lib目录中的,或者被-Xbootclasspath参数指定的路径并且被虚拟机识别的类库;
  • 扩展类加载器(Extension ClassLoader):负责加载/lib/ext/目录或者Java.ext.dirs系统变量指定的路径中的所有类库;
  • 应用程序类加载器(Application ClassLoader):负责加载用户类路径上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我们没有自定义类加载器默认使用的就是这个加载器;

什么是双亲委派模型

如果一个类加载器收到类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载器无法完成加载请求,子加载器才会尝试去加载类;

7. JVM调优

7.1 JVM调优工具

JVM调优常用的是下面这两个工具:

  • jconsole:用于对JVM中的内存、线程和类进行监控;
  • jvisualvm:JDK自带分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存变化以及gc变化;

7.2 JVM调优参数

常用的JVM调优参数如下:

  • -Xms2g:初始化堆大小为2g
  • -Xmx2g:堆最大内存为2g
  • -XX:NewRatio=4:设置年轻代和老年代的内存比例为1:4
  • -XX:SurvivorRatio=8:设置新生代Eden和Survivor比例为8:2
  • -XX:+UseParNewGC:指定使用ParNew + Serial Old垃圾回收器组合
  • -XX:+UseParallelOldGC:指定使用ParNew + ParNew Old垃圾回收器组合
  • -XX:+UseConcMarkSweepGC:指定使用CMS + Serial Old垃圾回收器组合
  • -XX:+PrintGC:开启打印gc信息
  • -XX:+PrintGCDetails:打印gc详细信息;

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!