JVM基础知识

工作中经常遇到的运行时异常:

  • 空指针
  • 内存溢出 oom(out of memory)。什么原因引起的?怎么定位?
  • 。。。

其他知识:

  • 创建新线程并启动。这段代码:“new Thread().start() ”无法确定线程是否启动。
    • 线程、进程与操作系统有关,跟Java语言无关,是操作系统去启动。查看start方法源码,里面会涉及到本地方法,交给操作系统去处理。
  • 程序=数据结构+算法。而我们真正实现的实际是:程序=业务需求+框架。
  • 队列和栈的区别。队列:先进先出。栈:先进后出,后进先出。(最先进来的作为栈底,最后进来的在栈顶)

JVM

一、JVM 体系结构概述

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

三种JVM:

  • Sun公司的 HotSpot VM(2009年Sun公司被Oracle收购)
    • 是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机
  • BEA公司的 JRockit VM(2008年BEA公司被Oracle收购)
  • IBM公司的 J9 VM

1、JVM位置和结构图

JVM(Java虚拟机)是运行在操作系统之上的,它与硬件没有直接的交互。

image-20191127094904025

JVM体系结构图

image-20191127095016919

(注:灰色部分表示没有垃圾回收,因为生命周期比较短,不需要。线程私有;橙色部分是有垃圾回收的,99%在堆中。生命周期比较长,线程共享)

说明:

  • 类加载器 将硬盘中的Java编译后的.class文件读到内存中。(类加载器相当于JVM的入口)
  • Execution Engine执行引擎 负责解释命令,提交操作系统执行。(执行引擎相当于出口)
  • 本地方法栈 是标记为native的方法才放入栈中。生命周期和线程一样,线程私有,没有垃圾回收。塞进栈的本地方法如果想要执行,Java无能为力了,需要求助操作系统,调用 本地方法接口 (这是操作系统的,c或c++接口)。如果调用操作系统接口,需要第三方(redis等)支持的,就需要 本地方法库 (比如windows下的 dll 动态连接库文件)。
  • 程序计数器 简称pc寄存器。每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。
    • 这块内存区域很小,它是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令
    • 如果执行的是一个Native方法,那这个计数器是空的。
    • 用以完成分支、循环、跳转、异常处理、线程恢复等基础功能。不会发生内存溢出(OutOfMemory=OOM)错误
  • Java栈 是生命周期和线程一样,线程私有,没有垃圾回收。
    • 基本类型的变量 + 引用类型变量 +实例方法 都是在函数的栈内存中分配。
  • Method Area方法区 是被所有线程共享的内存区域,此区属于共享区间。它存储了每一个类的结构信息,例如运行时常量池(Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的字节码内容。上面讲的是规范,在不同虚拟机里头实现是不一样的,最典型的就是永久代(PermGen space)元空间(Metaspace)
    • 静态变量(即类变量) + 常量 + 类信息(构造方法、接口定义)+运行时常量池 存在方法区中,
    • 但是实例变量存在堆内存中,和方法区无关。

2、类装载器ClassLoader

负责加载class文件,class文件在文件开头有特定的文件标示,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。

image-20191128150637049

【类加载器】种类:

image-20191128151232498
  • 虚拟机自带的加载器:
    • 1、启动类加载器(Bootstrap)C++
      • (不是Java的类加载器,在JVM启动时通过操作系统加载进来的。比如JDK自带的一些类,还有就是启动该程序需要引入的jar包,都是通过启动类加载器加载进来的)
    • 2、扩展类加载器(Extension)Java
    • 3、应用程序类加载器(App)Java
      • 也叫系统类加载器,加载当前应用的classpath的所有类
  • 用户自定义加载器:
    • 4、 Java.lang.ClassLoader的子类,用户可以定制类的加载方式

示例:

image-20191128153607630

sun.misc.Launcher 它是一个java虚拟机的入口应用。

【双亲委派】

当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。

采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object对象。

某个特定的类加载器在接到加载类的请求时,==首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载==。

比如自己定义的java.lang.String类,创建main函数,启动会报错。提示说找不到main函数。它是一层一层往上找,找到启动类加载器加载的那个。Java的沙箱安全机制就是保护自己原生JDK代码。

image-20191128154214970

3、本地方法栈、本地方法接口和本地方法库

Native Interface本地接口

  • Java语言本身不能对操作系统底层进行访问和操作,但是可以通过JNI接口调用其他语言来实现对底层的访问
  • 本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合 C/C++程序,Java诞生的时候是C/C++横行的时候,要想立足,必须有调用C/C++程序,于是就在内存中专门开辟了一块区域处理标记为Native的代码,它的具体做法是Native Method Stack中登记Native方法,在Execution Engine 执行时加载Native libraries。
  • 目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用WebService等等,不多做介绍。

Native Method Stack本地方法栈

​ 它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。

4、Java栈 stack

​ 栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over,生命周期和线程一致,是线程私有的。

8种基本类型的变量 + 引用类型变量 +实例方法都是在函数的栈内存中分配。

栈存储什么?栈帧中主要存储3类数据:

  • 本地变量(Localhost Variables): 输入参数和输出参数以及方法内的变量。
  • 栈操作(Operand Stack):记录出栈、入栈的操作。
  • 栈帧数据(Frame Data):包括类文件、方法等等。

==栈的运行原理:遵循“先进后出”/“后进先出”原则==。

栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法和运行期数据的数据集,当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压入栈,B方法又调用了C方法,于是产生栈帧F3也被压入栈,……执行完毕后,先弹出F3栈帧,再弹出F栈帧,再弹出F1栈帧……

每个方法执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法从调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。栈的大小和具体JVM的实现有关,通常在256K~756K之间,与等于1Mb左右

image-20191202165514044

图示在一个栈中有两个栈帧:栈帧 2是最先被调用的方法,先入栈,然后方法 2 又调用了方法1,栈帧 1处于栈顶的位置,栈帧 2 处于栈底,执行完毕后,依次弹出栈帧 1和栈帧 2,线程结束,栈释放。

每执行一个方法都会产生一个栈帧,保存到栈(后进先出)的顶部,顶部栈就是当前的方法,该方法执行完毕 后会自动将此栈帧出栈。

有个运行时错误叫做栈内存溢出(StackOverflowError):Exception in thread “main” java.lang.StackOverflowError。比如循环递归调用方法自己,就会出现。(这就是一直入栈不出栈,导致栈溢出。找到错误代码进行修改来解决。)

image-20191129131211882

【栈、堆、方法区的交互关系】

image-20191129131510826

HotSpot VM 是使用指针的方式访问对象:Java堆中会存放访问类元数据的地址,reference存储的就直接是对象的地址。

二、堆体系结构概述

JVM调优调的就是堆内存。堆是整个JVM的重点。内存溢出oom(out of memory)就是发生在堆中。

1、heap 堆(Java7之前)

一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行。

Java7及以前 堆内存逻辑上分为三部分:新生区+养老区+永久区

  • Young Generation Space 新生区 Young/New
    • Eden Space 伊甸区:new出来的对象都放在这(内存容量相对幸存区较大,但存活率比较低)
    • Survivor 0 Space 幸存0区:经历一次 Minor GC 垃圾回收幸存的对象放在这
    • Survivor 1 Space 幸存1区:经历两次 Minor GC 垃圾回收幸存的对象放在这,如果三次再回到0区,四次再到1区…
  • Tenure generation space 养老区 Old/Tenure:默认经过15次Minor GC 进入到这(新生区到养老区,内存容量相对较大)。养老区的gc是 FullGC 。养老区经过full gc还回收不了的对象,养老区满了,直接就会报Out of Memory内存溢出。(养老区不能到永久区)
  • Permanent Space 永久区 Perm
image-20191129135203449

【新生区】是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。新生区又分为两部分: 伊甸区(Eden space)和幸存者区(Survivor pace) ,所有的类都是在伊甸区被new出来的。幸存区有两个: 0区(Survivor 0 space)和1区(Survivor 1 space)(又为幸存from区、to区)。当伊甸区的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸区进行垃圾回收(Minor GC),将伊甸区中的不再被其他对象所引用的对象进行销毁。然后将伊甸区中的剩余对象移动到幸存0区.若幸存0区也满了,再对该区进行垃圾回收,然后移动到1区。(幸存者从0区挪到1区,从1区挪到0区)那如果1区也满了呢?再移动到【养老区】。若养老区也满了,那么这个时候将产生MajorGC(FullGC),进行养老区的内存清理。若养老区执行了Full GC之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”。

如果出现**java.lang.OutOfMemoryError: Java heap space**异常,说明Java虚拟机的堆内存不够。原因有二:

  • (1)Java虚拟机的堆内存设置不够,可以通过参数-Xms初始内存大小、-Xmx最大内存大小 来调整。
    • JVM heap -Xms初始内存大小,默认设置为主机物理内存的1/64;
    • -Xmx 最大内存大小,默认设置为主机物理内存的1/4。
  • (2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用),这就需要优化代码了(内存溢出优化的话需要专门的工具帮我们定位解决)。

image-20191202165934447

MinorGC的过程(复制->清空->互换):

  • 1:eden、SurvivorFrom 复制到 SurvivorTo,年龄+1
    • 首先,当Eden区满的时候会触发第一次GC,把还活着的对象拷贝到SurvivorFrom区,当Eden区再次触发GC的时候会扫描Eden区和From区域,对这两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域(如果有对象的年龄已经达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1
  • 2:清空 eden、SurvivorFrom
    • 然后,清空Eden和SurvivorFrom中的对象,也即复制之后有交换,谁空谁是to
  • 3:SurvivorTo和 SurvivorFrom 互换
    • 最后,SurvivorTo和SurvivorFrom互换,原SurvivorTo成为下一次GC时的SurvivorFrom区。部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代

堆内存在物理上分为:新生区+养老区

image-20191129142518020

真实物理上永久区就在方法区(Method Area),不是堆里面,虽然逻辑上算在堆中。

==永久代和方法区的关系==:永久代是HotSpot虚拟机的概念,很多开发者习惯将方法区称之为永久代,但严格本质上说两者不同。方法区是Java虚拟机规范中的定义,是一种规范(相当于一个接口Interface),而永久代是一种实现,一个是标准一个是实现。其他的虚拟机实现并没有永久带这一说法。虽然JVM规范将方法区描述为堆的一个逻辑部分,但它却还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。 永久代只是来实现方法区而已, 这样HotSpot可以像管理java的堆内存一样管理这部分的内存,能够省去专门为方法区编写内存代码管理的工作。

jdk1.7的版本中,已经将原本在永久代的字符串常量池移出,放在堆中。常量池(Constant Pool)是方法区的一部分,Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,这部分内容将在类加载后进入方法区的运行时常量池中存放。

在1.7之前在(JDK1.2 ~ JDK6)的实现中,HotSpot 使用永久代实现方法区,HotSpot 使用 GC分代来实现方法区内存回收,可以使用如下参数来调节方法区的大小:

1
2
3
4
5
-XX:PermSize
方法区初始大小
-XX:MaxPermSize
方法区最大大小
超过这个值将会抛出OutOfMemoryError异常:java.lang.OutOfMemoryError: PermGen

上图伊甸区和幸存区物理划分大小不一致。 由于绝大部分的对象都是短命的,甚至存活不到Survivor中(经研究,不同对象的生命周期不同,98%的对象是临时对象),所以,Eden区与Survivor的比例较大,HotSpot默认是 8:1:1,即分别占新生代的80%,10%,10%。如果一次回收中,Survivor+Eden中存活下来的内存超过了10%,则需要将一部分对象分配到 老年代。用-XX:SurvivorRatio参数来配置Eden区域Survivor区的容量比值,默认是8,代表Eden:Survivor1:Survivor2=8:1:1。

上图【新生区】New/Young由Eden、两块相同大小的Survivor(又称为from/to,s0/s1)构成,区分s0和s1谁是from和to:拷贝到另一个Survivor区总是空的就是to区,即to区总为空。

上图【养老区】Old 存放新生代中经历多次GC仍然存活的对象。比如所有的池对象(连接池、线程池等)都会进入到养老区。

【永久区(java7之前)】永久存储区是一个常驻内存区域,用于存放JDK自身所携带的Class、Interface的元数据,还有引用的jar包也会被放进该区域。也就是说它存储的是运行环境必须的类信息。被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭JVM才会释放此区域所占用的内存。

如果出现**java.lang.OutOfMemoryError: PermGen space**,说明是Java虚拟机对永久代Perm内存设置不够。一般出现这种情况,都是程序启动需要加载大量的第三方jar包。例如:在一个Tomcat下部署了太多的应用。或者大量动态反射生成的类不断被加载,最终导致Perm区被占满。现在Maven工程,配好所需的框架,它会根据实际情况下载所需要的jar包,不会出现各种版本的jar包都出现。所以这个错误已经成为历史了。

永久区的发展:

  • JDK1.6及之前:有永久代,常量池1.6在方法区, 并且其中只能够存放字符串的实例 ;
  • JDK1.7:有永久代,但已经逐步“去永久代”,常量池1.7在堆内存中, 可以存储的是字符串对象的实例和堆内存中字符串对象的引用 ;
  • JDK1.8及之后:无永久代,常量池1.8在元空间(依然在方法区,元空间是它的另一个实现), 字符串常量池还是在堆内存当中 。(被收购,开发人员变了,有新的理念…)

Java8及之后,堆内存逻辑上分为 新生区+养老区+元空间,物理上分为新生区+养老区

三、堆参数调优

Java7

image-20191129173733662

Java8:JDK 1.8之后将最初的永久代取消了,由元空间取代。元空间的本质和永久代类似。

image-20191129173854648

元空间与永久代之间最大的区别在于:永久代使用的JVM的堆内存,但是java8以后的元空间并不在虚拟机中而是使用本机物理内存

因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制。

调优参数说明:

image-20191129174127237

如果出现运行比较慢、卡,出现OutOfMemoryError时可以调整该参数,调大一点可以保证Java程序运行正常。

示例:查看各参数情况。运行后最大内存值和初始内存值大小基本分别为电脑物理内存的1/4和1/64。

1
2
3
4
5
6
public static void main(String[] args){
long maxMemory = Runtime.getRuntime().maxMemory() ;//返回 Java 虚拟机试图使用的最大内存量。
long totalMemory = Runtime.getRuntime().totalMemory() ;//返回 Java 虚拟机中的内存总量。初始内存大小
System.out.println("MAX_MEMORY = " + maxMemory + "(字节)、" + (maxMemory / (double)1024 / 1024) + "MB");
System.out.println("TOTAL_MEMORY = " + totalMemory + "(字节)、" + (totalMemory / (double)1024 / 1024) + "MB");
}

发现默认的情况下分配的内存是总内存的“1 / 4”、而初始化的内存为“1 / 64”。

1
2
3
运行结果如下:当前主机物理内存为8110MB
MAX_MEMORY = 1890582528(字节)、1803.0MB
TOTAL_MEMORY = 128974848(字节)、123.0MB

image-20191202100704207

如何调整初始内存和最大内存大小:VM参数: -Xms1024m -Xmx1024m -XX:+PrintGCDetails

IDEA设置示例:

image-20191202101219234

Java7

image-20191202113404265

Java8

image-20191202113518393

定位问题:

  • IDEA下 启动参数设置 VM options:-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\xx文件目录。
    • -XX:+HeapDumpOnOutOfMemoryError:OOM时导出堆到后缀为hprof的dump文件。
    • -XX:HeapDumpPath :生成的dump文件存放路径。
    • 如何查看后缀为hprof的dump文件:在Java jdk的bin目录下有个jvisualvm.exe可执行文件,使用它来解读dump文件。

image-20191202141906627

四、GC 垃圾回收

GC是什么?(分代收集算法):

  • 次数上频繁收集Young区;
  • 次数上较少收集Old区;
  • 基本不动Perm区。

GC算法总体概述:

image-20191202170855922

JVM 在进行GC时,并非每次都是对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。因此GC按照回收的区域又分为了两种类型:一种普通GC(minor GC),一种是全局GC(major GC or Full GC)。

  • 普通GC(minor GC):只针对新生代区域的GC;
  • 全局GC(major GC or Full GC):针对年老代的GC,偶尔伴随对新生代的GC以及对永久代的GC。

GC四大算法:

  • 1、引用计数法

  • 应用:微软的COM/ActionScript3/Python……

  • 缺点:①、每次对对象赋值时均要维护引用计数器,且计数器本身也有一定的消耗;②、较难处理循环引用。

  • JVM的实现一般不采用这种方式。

  • 2、复制算法(Copying)

  • ==年轻代中使用的是Minor GC==,这种GC算法采用的是复制算法。

  • 复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片

  • 原理:Minor GC会把Eden中的所有活的对象都移到Survivor区域中,如果Survivor区中放不下,那么剩下的活的对象就被移到Old generation中,也即一旦收集后,Eden是就变成空的了。在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

    • -XX:MaxTenuringThreshold:设置对象在新生代中存活的次数(年龄)
    • 因为Eden区对象一般存活率较低,一般的,使用两块10%的内存作为空闲和活动区间,而另外80%的内存,则是用来给新建对象分配内存的。一旦发生GC,将10%的from活动区间与另外80%中存活的eden对象转移到10%的to空闲区间,接下来,将之前90%的内存全部释放,以此类推。

    image-20191203092808457

    示例:

    img

  • 优点:没有标记和清除的过程,效率高(扫描一次就可以了);没有内存碎片(复制过去的对象都是在放在一片连续内存区域)

  • 缺点:1、(需要双倍内存空间)它浪费了一半的内存,这太要命了;2、如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。 所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。

  • 3、标记清除(Mark-Sweep)

    • ==老年代一般是由标记清除或者是标记清除与标记整理的混合实现==。(full GC)

    • 原理:算法分为标记和清除两个阶段,先标记要回收的对象,然后统一回收这些对象

      image-20191203093443782

      示例:

      img

    • 用通俗的话解释一下标记清除算法,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将要回收的对象标记一遍,最终统一回收这些对象,完成标记清理工作接下来便让应用程序恢复运行。

    • 优点:不需要额外空间;

    • 缺点:两次扫描,耗时严重;此算法需要暂停整个应用,会产生内存碎片。

      • 1、首先,它的缺点就是效率比较低(递归与全堆对象遍历),而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲
      • 2、其次,主要的缺点则是这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。
  • 4、标记压缩(Mark-Compact):标记整理

    • 老年代一般是由标记清除或者是标记清除与标记整理的混合实现

    • 原理:

      image-20191203094809757
    • 在整理压缩阶段,不再对标记的对像做回收,而是通过所有存活对像都向一端移动,然后直接清除边界以外的内存。可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

    • 标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。

    • 优点:没有内存碎片;

    • 唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。

  • 5、标记清除压缩(Mark-Sweep-Compact)

    • 原理:1、Mark-Sweep和Mark-Compact的结合;2、和Mark-Sweep一致,当进行多次GC后才compact。减少移动对象的成本。

    • 示例:

      img

【总结】

  • 内存效率:复制算法>标记清除算法>标记整理算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
  • 内存整齐度:复制算法=标记整理算法>标记清除算法。
  • 内存利用率:标记整理算法=标记清除算法>复制算法。

可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所提到的三个指标,标记/整理算法相对来说更平滑一些,但效率上依然不尽如人意,它比复制算法多了一个标记的阶段,又比标记/清除多了一个整理内存的过程。

难道就没有一种最优算法吗?

回答:无,没有最好的算法,只有最合适的算法。====> ==分代收集算法==。

  • 年轻代(Young Gen)
    • 年轻代特点是区域相对老年代较小,对象存活率低
    • 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因而很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
  • 老年代(Tenure Gen)
    • 老年代的特点是区域较大,对象存活率高
    • 这种情况,存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记整理的混合实现。
    • Mark阶段的开销与存活对象的数量成正比,这点上说来,对于老年代,标记清除或者标记整理有一些不符,但可以通过多核/线程利用,对并发、并行的形式提标记效率。
    • Sweep阶段的开销与所管理区域的大小形正相关,但Sweep“就地处决”的特点,回收的过程没有对象的移动。使其相对其它有对象移动步骤的回收算法,仍然是效率最好的。但是需要解决内存碎片问题。
    • Compact阶段的开销与存活对象的数据成开比,如上一条所描述,对于大量对象的移动是很大开销的,做为老年代的第一选择并不合适。
    • 基于上面的考虑,老年代一般是由标记清除或者是标记清除与标记整理的混合实现。以hotspot中的CMS回收器(Java8)为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高,而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器做为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。

【面试题】

  1. JVM内存模型以及分区,需要详细到每个区放什么
  2. 堆里面的分区:Eden,survival from to,老年代,各自的特点。
  3. GC的三种收集方法:标记清除、标记整理、复制算法的原理与特点,分别用在什么地方
  4. Minor GC与Full GC分别在什么时候发生
文章目录
  1. 1. JVM
    1. 1.1. 一、JVM 体系结构概述
      1. 1.1.1. 1、JVM位置和结构图
      2. 1.1.2. 2、类装载器ClassLoader
      3. 1.1.3. 3、本地方法栈、本地方法接口和本地方法库
      4. 1.1.4. 4、Java栈 stack
    2. 1.2. 二、堆体系结构概述
      1. 1.2.1. 1、heap 堆(Java7之前)
    3. 1.3. 三、堆参数调优
    4. 1.4. 四、GC 垃圾回收
|