jvm

什么是JVM?

是一个专门运行class字节码文件的操作系统,是用c语言开发的,不同的系统都有对应版本的jvm。专门屏蔽了底层的操作系统、硬件、CPU指令等层面上的细节,让字节码只面对jvm,不用去关注操作系统、硬件的差异。

JVM的组成

jvm由垃圾回收器、类加载器、运行时数据区、执行引擎、本地方法库组成。

其中运行时数据区是重点,也叫java内存部分,其余还可以关注垃圾回收器和类加载器。

1.类加载器

负责查找并装载类的部分称为类加载子系统,类加载子系统用于定位和加载编译后的class文件。

下面是类加载的整个生命周期:

graph LR A[字节码] -->B[类加载器]-->C[链接]-->D[初始化]-->E[使用]-->F[卸载]

其中链接这一步又细分为三步:

graph LR A[验证]-->B[准备]-->C[解析]

类加载器将类加载完毕后会将类的信息加入到方法区(元空间)

类加载机制

类加载器主要是实现类的加载,将类加载到java内存中。

双亲委派机制:

可以用一句话概括:从下而上检查,至上而下加载。那么怎么理解呢?

在了解类怎么加载之前,首先要知道类加载器有很多种:启动类加载器扩展类加载器应用类加载器

启动类加载器:又叫根加载器/引导类加载器,负责加载JAVA中的一些核心类库,主要是位于<JAVA_HOME>/lib/rt.jar中。

扩展类加载器:主要加载JAVA中的一些拓展类,位于<JAVA_HOME>/lib/ext中,是启动类加载器的子类。

应用类加载器:又称为系统类加载器,主要用于加载CLASSPATH路径下我们自己写的类,是拓展类加载器的子类。

在知道有多少种类加载器之后,再介绍下类的加载机制,主要有全盘负责父亲委托缓存机制

在加载类的时候,这里指的类是自己写的类,用的是应用类加载器,会先判断这个类是否被载入过(即判断该类在缓存区是否有此Class),这里可以看下面这个例子:

public class String {
public static void main(String[] args) {
  System.out.println("1111");
}
}

这里定义了一个String类,我们都知道String类肯定是存在的,所以这个时候运行的话,类加载器会先判断这个类是否存在。如果存在,则返回已经存在的类。

那么类加载器是怎么判断这个类是否存在的。当类加载器在加载这个类的时候,会一层一层的往上找(这个过程可以理解为向上委托给父类加载器去完成),就这样一直到启动类加载器。如果找到了,比如一直委托到了启动类加载器,那么启动类加载器就会检查是否能够加载这个类,如果能加载,则到这一层结束。如果不能,则会通知子类加载器进行加载,一直通知到应用类加载器。----这个就是双亲委派机制(检查顺序从下至上,加载顺序从上至下)。

ps:应用类加载器的父亲是扩展类加载器,扩展类加载器的父亲是启动类加载器。

类的装载是指将.class文件中的二进制数据读到内存中,将其放在运行时数据区的方法区内,然后在堆创建一个class对象(也就是常说的对象),这个对象封装了在方法区内的数据结构,并且向开发者提供了访问方法区内数据结构的接口。

2.运行时数据区(java内存区域)

运行时数据区又叫java的内存区域,内存区域主要分为两部分:线程私有线程公有

其中元空间是线程公有的,虚拟机栈程序计数器本地方法栈是线程私有的。

一般而言线程公有的区域都会存在线程安全的问题。

程序计数器:

程序计数器主要是用于记录程序执行的位置和行号,是一块很小的区域,不会存在内存溢出的问题。

虚拟机栈:

虚拟机栈又叫方法栈、线程栈。虚拟机栈采用的是栈的数据结构也就是先进后出,后进先出的机制。

在虚拟机栈中,入口和出口都是同一个口,虚拟机栈主要是执行方法的。方法的执行也叫压栈,方法执行结束就叫出栈。

一个方法执行就会创建一个栈帧,栈中可以压很多的栈帧,一个栈帧对应一个方法。

栈帧分为四个部分:局部变量表、操作数栈、动态链接、返回地址。

局部变量

假设现在有一个方法开始执行,也就是开始压栈,方法中可能会存在局部变量,那么这些局部变量会存在栈帧的局部变量表中。如果局部变量是基本数据类型,那么就会直接存在局部变量表中,如果是引用数据类型,那么只会把引用数据类型的地址存在局部变量表中,具体的引用类型数据则放在堆中,相当于局部变量表存了引用数据类型的一个指针,指向这个数据的在堆中的位置。

操作数栈

操作数栈指的是在方法中可能存在运算指令,那么这个运算指令会在操作数栈中进行操作。

动态链接

动态链接指的是在这个方法中可能会存在调用其他方法,那么就需要找到这个被调用的方法在哪,方法是放在元空间/方法区中的,方法只有一个,但类可以创建n个对象,所以这个方法可能会被调用n次,所以动态链接存的是被调用方法的一个内存地址,指向的是存在于元空间的方法。

返回地址

返回就比如返回方法的执行情况,比如成功或者失败或者异常之类的。

虚拟机栈可能会出现的问题

虚拟机栈溢出:

栈的深度大于虚拟机所允许的深度会出现StackOverflowError

常见于递归调用。

当虚拟机栈没有空间且无法申请更多的空间的时候会出现OutOfMemoryError

一般多发生于多线程条件下,建立过多的线程且每个线程运行时间又比较长,这时可能会出现栈的内存溢出。

栈可以通过-Xss1M进行大小设置

本地方法栈

本地方法栈是为本地方法服务的,和虚拟机栈一样,当执行本地方法的时候,一样会进行压栈操作,这时会把栈帧压到本地方法栈而不是虚拟机栈,本地方法都是用native关键字进行标记。

方法区/元空间

方法区/元空间是线程共享的,用于存储类的信息、常量、运行时常量池、静态变量、即时编译器编译后的代码等。在java虚拟机规范中描述的方法区是堆的一个逻辑部分,也就是说方法区在逻辑上是属于堆的一部分,但实际上为了和堆做区分,方法区也被称为非堆。在HotSpot虚拟机中,使用永久代来作为方法区的落地实现,但是因为一些问题,后面逐渐使用本地内存来作为方法区的落地实现。

ps:在1.7及其之前,方法区在hotspot中都是永久代,直到1.8版本,永久代变成了使用本地内存的元空间。在1.7版本的时候,将字符串常量池从方法区中去除,放在了堆中。

1.7及其之前的版本可以通过-XX:MaxPermSize来设置最大值。

1.8开始通过-XX:MaxMetaspaceSize=48m来进行设置。

空间不够分配时会出现OutOfMemoryError的问题。

比如:

-XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:-UseCompressedClassPointers 
-XX:MetaspaceSize=20M -XX:MaxMetaspaceSize=20m

第一个参数用于打印GC日志;

第二个参数用于打印对应的时间戳;

第三个参数-XX:-UseCompressedClassPointer表示在Metaspace中不要开辟出一块新的空间(Compressed Class Space),如果开辟这块空间的话,该空间默认大小是1G,所以我们关闭该功能。最后再设置元空间的值为20M,最大值为20m

元空间溢出

元空间有默认的大小,一旦元空间存放class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等或者通过反射大量生成动态类填充该区域超出大小范围即会发生内存溢出;

1.7之前的元空间溢出:

OutOfMemoryError:PermGen space;

1.7及1.8的元空间溢出

OutOfMemoryError:Metaspace;

1.8元空间设置大小

-XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M

堆是线程共享的,是java内存中最大的区域,只要虚拟机启动就会创建堆,用于存放所有的实例对象或者数组,是垃圾回收器主要管理的区域,堆可划分为新生代和老年代。

堆可以通过-Xmx-Xms来调节堆的大小。

堆会出现OutOfMemoryError:Java heap space

新生代可细分为:伊甸区、from区和to区

新生代是类诞生,成长甚至死亡的地方。(活的足够长的类,可能会进入老年区,但是大部分在新生区就玩完了),所有的对象一开始都是在伊甸园区new出来的。

新生区分为三部分:Eden区,新生0区,新生1区或幸存者区(0,1)或者from区和to区。按照8:1:1进行分配。

​ 新生区会引发轻gc(minorgc),养老区会引发重gc(fullgc),垃圾回收主要发生在新生区和养老区,新生区到养老区触发fullgc

新生代占整个堆的1/3,老年代占2/3。其中伊甸区和from区、to区又按照8:1:1的比例分配堆的1/3。

概念:

内存溢出:

​ GC Roots到对象之间无可达路径,可以被收集,但对象还存活着(有可能是垃圾回收器还来不及回收),这个可以通过调节参数来分析对象的生命周期是否过长,对象是否持有状态时间过长。

内存泄漏:

​ GC Roots到对象之间有可达路径而无法收集

可达路径:

​ 假设A对象持有B对象,B对象持有C对象。这时C对象和B对象可能已经不用了,但A对象还在用,而又由于A持有B,B持有C,所以A的不能回收导致B和C都不能回收。这就叫可达路径,其中A可以被称为GC Roots

3.JVM垃圾回收

垃圾回收器主要关注线程共享的部分:堆和方法区/元空间。

哪些需要回收?什么时候回收?如何回收?

堆创建对象的过程:

当通过 new 创建对象时,首先检查这个new指令的参数是否能在元空间中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过,如果没有,执行相应的类加载;

类加载检查通过之后,为新对象分配内存(内存大小在类加载完成后便可确定),在堆的空闲内存中划分一块区域(‘指针碰撞’或‘空闲列表’的分配方式);

为对象分配内存空间相当于把一块确定大小的内存从堆中划分出来,分配内存的方式有两种,使用哪一种方式取决于堆是否规整,而堆是否规整是由堆采用的垃圾收集器是否带有压缩整理的功能。SerialParNew垃圾收集器是带有压缩整理功能的,CMS垃圾收集器是不带有压缩整理功能的。

带有压缩整理功能的收集器的堆是规整的,会把用过的内存放在一边,没用过的内存放在另一边,中间放一个指针作为分界面的指示器,那么分配内存就是把指针往空间的那一边移动和对象大小相同的距离,这种分配方式称为指针碰撞

不带有压缩整理功能的收集器的堆是不规整的,已使用的内存和使用的内存相互交错,虚拟机会有一个列表,上面记录的是哪些内存是空间的,在分配的时候会从列表中找到合适的空间划分给对象,并且更新列表上的记录,这种分配方式称为空闲列表

由于堆中分配内存非常频繁,为了避免多个线程同时分配堆内存时的冲突,虚拟机采用CAS和失败重试方式保证操作的线程安全,同时虚拟机还有另一套设计就是把每个线程分配堆内存的动作隔离开,即每个线程预先在堆中分配一块内存,称为线程分配缓冲(TLAB->Thread Local Allocation Buffer),线程先在自己的TLAB上分配,分配完了再CAS同步,这样可以很大程度避免在并发情况下频繁创建对象造成的线程不安全;

内存空间分配完成后会初始化为 0(不包括对象头),接下来就是填充对象头,把对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息存入对象头。

执行 new 指令后执行 init 方法后才算一份真正可用的对象创建完成;

对象在内存中的布局:

在虚拟机中,对象在内存中分为 3 块区域:对象头(Header)、实例数据(Instance Data) 和 对齐填充(Padding)。

  • 对象头

包含两部分,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,32 位虚拟机占 32 bit,64 位虚拟机占 64 bit。官方称为 ‘Mark Word’;

第二部分是类型指针,即对象指向它的类的元数据指针,虚拟机通过这个指针确定这个对象是哪个类的实例,另外,如果是 Java 数组,对象头中还必须有一块用于记录数组长度的数据,因为普通对象可以通过 Java 对象元数据确定大小,而数组对象不可以;

  • 实例数据

程序代码中所定义的各种成员变量类型的字段内容(包含父类继承下来的和子类中定义的);

  • 对齐填充

不是必然需要,主要是占位,保证对象大小是某个字节的整数倍;

如何判断可以回收--可达性路径分析:

jvm通过可达性分析算法来判断是否可以回收,之前说过可达性就相当于持有对象的引用,持有方只要还存在,那被持有方一样是不能回收的。

机制是通过一个对象(这个对象被称为GC Root)为根节点,以这个根节点为起始点开始搜索,看这个对象和其他对象有没有可达路径,如果没有则表示该对象是不可用的,可以被回收。

那么哪些对象可以作为GC Root:

  1. 虚拟机栈本地变量表中引用类型所引用的对象;

  2. 方法区/元空间中类的静态变量所引用的对象;

  3. 方法区/元空间中类的常量所引用的对象;

  4. 本地方法栈中Native方法里所引用的对象;

jvm主要是通过引用来分析是否有可达路径的。

主要有四种引用:

  1. 强引用

    强引用是最普遍的引用,比如 User user = new User(); 这是强引用,垃圾收集器不会回收强引用;

  2. 软引用

    软引用是表示一些对象还有用,但是也不是必须要用的,比如像缓存对象就可以采用软引用,在系统内存不足时,这些软引用是可以被垃圾回收器回收的,如果我们要使用软引用的话,编码的时候,把对象采用jdk中提供的SoftReference类型来包装;

  3. 弱引用

    弱引用也是表示一些不是必须的对象,但它比软引用更弱,被弱引用所引用的对象只能生存到下一次垃圾收集之前,当开始垃圾收集时,无论内存是否足够,都会回收弱引用对象;

  4. 虚引用

    虚引用PhantomReference是一种特殊的引用,也是最弱的引用关系,用来实现Object.finalize功能,在开发中很少使用;

方法区/元空间的垃圾回收

在JVM的垃圾回收中,堆内存是回收最频繁也是最多的,方法区/元空间的垃圾收集效率非常低,所以JVM规范中并没有要求一定要回收方法区/元空间,如果方法区/元空间有无用的类信息、常量池,JVM不是必须要回收的;

Hotspot 虚拟机默认会进行类的卸载,如果不想要对无用的类进行卸载,可以加上参数-Xnoclassgc(不卸载),默认情况下Hotspot 虚拟机会卸载类;

类的加载和卸载参数:

-XX:+PrintGCDetails -XX:+PrintGCDateStamps -verbose:class -XX:+TraceClassLoading -XX:+TraceClassUnloading

方法区/元空间垃圾回收主要两部分内容:废弃的常量和无用的类;

判断废弃常量:一般是判断没有任何对象引用该常量;

判断无用的类:要满足以下三个条件

(1)该类所有的实例都已经回收,也就是 Java 堆中不存在该类的任何实例;

(2)加载该类的 ClassLoader 已经被回收;

(3)该类对应的 java.lang.Class 对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法;

JVM回收对象的两次标记过程

1、第一次标记

如果对象进行可达性分析算法之后没有发现与GC Roots相连的引用链,那它将会第一次标记并且进行一次筛选;

筛选条件:判断此对象是否有必要执行finalize()方法;

筛选结果:当对象没有覆盖finalize()方法或者finalize()方法已经被JVM执行过,则判定为可回收对象,如果对象有必要执行finalize()方法,则被放入F-Queue队列中,稍后在JVM自动建立低优先级的Finalizer线程(可能多个线程)中触发这个方法;  

回收之前会调用finalize()方法,该方法是用于销毁的,可以重写这个方法,如果对象没有重写该方法或者已经执行过这个方法,则该对象被标记为可回收对象,并放入F-Queue队列中。

2、第二次标记

GC对F-Queue队列中的对象进行二次标记;

如果对象在finalize()方法中重新与引用链上的任何一个对象建立了关联,那么第二次标记时则会将它移出“即将回收”集合,如果此时对象还没成功逃脱,那么只能被回收了;

注:finalize() 方法

finalize()是Object类的一个空方法、该方法是被垃圾收集器所调用,一个对象的finalize()方法只会被垃圾收集器自动调用一次,经过finalize()方法逃脱死亡的对象,第二次不会再调用;

不提倡在程序中调用finalize()来进行对象的自救,因为该方法执行的时间不确定,甚至是否被执行也不确定(Java程序的不正常退出),无法保证各个对象的调用顺序(甚至有不同线程中调用)。

第二次标记会走一遍第一次的过程,第二次允许退回第一次的操作,也就是如果在F-Queue队列中的对象被重新引用了,那么就会被移除“即将回收”的集合。

垃圾回收算法
  • 复制算法

    将可用内存按容量分为大小相等的两块,每次只使用其中一块,当这一块的内存用完了,就将还存活的对象复制到另外一块内存上,然后再把已使用过的内存空间一次清理掉;

    优点:实现简单,效率高,解决了标记-清除算法导致的内存碎片问题;

    缺点:代价太大,将可分配内存缩小了一半,效率随对象的存活率升高而降低。

    一般虚拟机都会采用该算法来回收新生代;

  • 标记-清除算法

    分为‘ 标记 ’和‘ 清除 ’两个阶段:

    标记

    标记出所有需要回收的对象,这里面会经历两次标记(可达路径分析),一次标记和二次标记,经过两次标记的对象就可以判定可以回收了;

    清除

    两次标记后,对还在“ 即将回收 ”集合的对象进行回收;

    优点:基于最基础的可达性分析算法,实现简单,后续的收集算法都是基于这种思想实现的;

    缺点:标记和清除效率不高,产生大量不连续的内存碎片,导致创建大对象时找不到连续的空间,不得不提前触发另一次的垃圾回收;

  • 标记-整理算法

    标记-整理算法是根据老年代的特点而产生的;

    1 标记

    标记过程与上面的标记-清理算法一致,也是基于可达性分析算法,也是两次标记;

    2 整理

    和标记-清理不同的是,该算法不是针对可回收对象进行清理,而是根据存活对象进行整理。让存活对象都向一端移动,然后直接清理掉边界以外的内存;

    优点:不会像复制算法那样划分两个区域,提高了空间利用率,不会产生不连续的内存碎片;

    缺点:效率问题,除了像标记-清除算法的标记过程外,还多了一步整理过程,效率变低;

  • 分代收集算法

    现在一般虚拟机的垃圾收集都是采用“ 分代收集 ”算法;

    根据对象存活周期的不同将内存划分为几块,一般把java堆分为新生代和老年代,JVM根据各个年代的特点采用不同的收集算法;

    新生代中,每次进行垃圾回收都会发现大量对象死去,只有少量存活,因此采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;

    老年代中,因为对象存活率较高,采用标记-清理、标记-整理算法来进行回收;

垃圾收集器

新生代收集器:Serial、ParNew、Parallel Scavenge

老年代收集器:CMS、Serial Old、Parallel Old

整堆收集器: G1

Serial

新生代收集器,最早的收集器,单线程的,收集时需暂停用户线程的工作。所以有卡顿现象,效率不高,致使java语言的开发团队一直在改进垃圾收集器的算法和实现。但Serial收集器简单,不会有线程交互的开销,是client模式下默认的垃圾收集器。

-client, -server;

jdk1.8之后就默认是-server模式,在这之前是-client模式,可以通过java -version来查看使用的是哪种模式。-server比-client效率要高。

参数: -XX:+UseSerialGC

ParNew

多线程版的Serial。

它是新生代收集器,就是Serial收集器的多线程版本,大部分基本一样,配置参数也一致,单CPU下,ParNew还需要切换线程,可能还不如Serial。

Serial和ParNew收集器可以配合CMS收集器,前者收集新生代,后者CMS收集老年代。

"-XX:+UseConcMarkSweepGC":指定使用CMS后,会默认使用ParNew作为新生代收集器;
"-XX:+UseParNewGC":强制指定使用ParNew;
"-XX:ParallelGCThreads=2":指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同;

GC日志常用参数

-XX:+PrintGC

允许在每个GC上打印消息。默认情况下,此选项处于禁用状态。

-XX:+PrintGCApplicationConcurrentTime

启用打印自上次暂停(例如GC暂停)以来经过的时间。默认情况下,此选项处于禁用状态。

-XX:+PrintGCApplicationStoppedTime

允许打印暂停(例如GC暂停)持续的时间。默认情况下,此选项处于禁用状态。

-XX:+PrintGCDateStamps

启用在每个GC上打印日期戳。默认情况下,此选项处于禁用状态。

-XX:+PrintGCDetails

允许在每个GC上打印详细消息。默认情况下,此选项处于禁用状态。

-XX:+PrintGCTaskTimeStamps

启用为每个GC工作线程任务打印时间戳。默认情况下,此选项处于禁用状态。

-XX:+PrintGCTimeStamps

启用在每个GC上打印时间戳。默认情况下,此选项处于禁用状态。

-Xloggc:filename

设置要将详细GC事件信息重定向到其中进行日志记录的文件。写入此文件的信息类似于-verbose:gc的输出,其中包含自每个记录的事件之前的第一个gc事件以来经过的时间。-Xloggc选项重写-verbose:gc,如果这两个选项都是用同一个java命令给出的。

-XX:+HeapDumpOnOutOfMemoryError

启用在引发Java.lang.OutOfMemoryError异常时使用堆探查器(HPROF)将Java堆转储到当前目录中的文件。可以使用-XX:heap dump path选项显式设置堆转储文件的路径和名称。默认情况下,此选项被禁用,并且在引发OutOfMemoryError异常时不会转储堆。

-XX:HeapDumpPath=path

设置在设置-XX:+HeapDumpOnOutOfMemoryError选项时用于写入堆分析器(HPROF)提供的堆转储的路径和文件名。默认情况下,文件是在当前工作目录中创建的,名为java_pidpid.hprof,其中pid是导致错误的进程的标识符。

例子:

-XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:d:/jvm/jvmgc.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=d:/jvm/heapdump.hprof

posted @ 2021-06-20 17:05  zhaojunjin  阅读(326)  评论(0编辑  收藏  举报