JVM(一)——JVM自动内存管理

目录

一、前言

1.1 计算机==>操作系统==>JVM

1.1.1 虚拟与实体(对上图的结构层次分析)

1.1.2 Java程序执行(对上图的箭头流程分析)

二、JVM内存空间与参数设置

2.1 运行时数据区

2.2 关于StackOverflowError和OutOfMemoryError

2.2.1 StackOverflowError

2.2.2 OutOfMemoryError

2.3 JVM堆内存和非堆内存

2.3.1 堆内存和非堆内存

2.3.2 JVM堆内部构型(新生代和老年代)

2.4 JVM堆参数设置

2.4.1 JVM重要参数

2.4.2 JVM其他参数

2.5 从日志看JVM(开发实践)

三、HotSpot VM

3.1  HotSpot VM相关知识

3.2 HotSpot VM的两个实现与查看本机HotSpot

四、JVM内存回收

4.1 垃圾收集算法(内存回收理论)

4.1.1 标记-清除算法

4.1.2 复制算法

4.1.3 标志-整理算法

4.1.4 分代收集算法

4.2 垃圾收集器(内存回收实践)

4.2.1 常用组合1:Serial + serial old  新生代和老年代都是单线程,简单

4.2.2 常用组合2:ParNew+ serial old   新生代多线程,老年代单线程,简单

4.2.3 常用组合3:Parallel scavenge + Parallel old  该组合完成吞吐量优先虚拟机,适用于后台计算

4.2.4 常用组合4:cms收集器  完成响应时间短虚拟机,适用于用户交互

4.2.5 常用组合5:G1收集器  面向服务端的垃圾回收器

4.3 垃圾收集器常用参数

五、JVM内存分配

5.1 对象优先在Eden上分配

5.1.1 设置VM Options

5.1.2 程序输出(给出附加解释)

5.2 大对象直接进入老年代(使用-XX:PretenureSizeThreshold参数设置)

5.2.1  设置VM Options

5.2.2 程序输出(给出附加解释)

5.3 长期存活的对象应该进入老年代(使用-XX:MaxTenuringThreshold参数设置)

5.3.1 设置VM Options

5.3.2 程序输出(给出附加解释)

六、小结


一、前言

笔者JVM博客一共四篇,分别是  "JVM内存管理"  "JVM执行子系统"  "JVM优化"   "JVM并发",网上类似JVM的博客有很多,笔者JVM四篇中尽量保证更少的内容与已存在的博客雷同,即网上都有的尽量略过,尽量多一些自己的理解,一些新的东西。 当然,笔者的博客也借鉴网上很多的资源,在此谢过。

对于Java虚拟机在内存分配与回收的学习,如果读者大学时代没有偷懒的话,操作系统和计算机组成原理这两门功课学的比较好的话,理解起来JVM是比较容易的,只要底子还在,很多东西都可以触类旁通。

1.1 计算机==>操作系统==>JVM

JVM全称为Java Virtual Machine,译为Java虚拟机,读者会问,虚拟机虚拟的是谁呢?即虚拟是对什么东西的虚拟,即实体是什么,是如何虚拟的?下面让我们来看看“虚拟与实体”。

一图解析计算机、操作系统、JVM三者关系

       

1.1.1 虚拟与实体(对上图的结构层次分析)

JVM之所以称为之虚拟机,是因为它是实现了计算机的虚拟化。下表展示JVM位于操作系统堆内存中,分别实现的了对操作系统和计算机的虚拟化。

  实体 在JVM上虚拟
程序计数器(寄存器方面) 计算机CPU控制器中的PC寄存器,用于存放当前正在执行指令的地址(注意存放的是指令地址,不是指令本身) JVM程序计数器,本质上用于对计算机CPU控制器中的PC程序计数器进行虚拟
内存 计算机内存,用于存放当前正在运行的程序 JVM内存,本质上是对计算机内存的虚拟
磁盘 计算机磁盘,用于存放计算机上的程序 JVM方法区,本质上是对计算机磁盘的虚拟

操作系统栈,

一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收,分配方式类似于链表。

JVM栈,用于存放基本数据类型变量,存放对象引用(即引用类型变量的引用),本质上是对操作系统栈的虚拟

操作系统堆,

由操作系统自动分配释放,存放函数的参数值,局部变量值等。操作方式与数据结构中的栈相类似。

JVM堆,用于存放引用类型变量,本质上是对操作系统堆的虚拟

1.1.2 Java程序执行(对上图的箭头流程分析)

上图中不仅是结构图,展示JVM的虚拟和实体的关系,也是一个流程图,上图中的箭头展示JVM对一个对象的编译执行,

程序员写好的类加载到虚拟机执行的过程是:当一个classLoder启动的时候,classLoader的生存地点在JVM中的堆,首先它会去主机硬盘上将Test.class装载到JVM的方法区,方法区中的这个字节文件会被虚拟机拿来new Test字节码(),然后在堆内存生成了一个Test字节码的对象,最后Test字节码这个内存文件有两个引用一个指向Test的class对象,一个指向加载自己的classLoader。整个过程上图用箭头表示,这里做说明。

就像本文开始时说过的,有了计算机组成原理和操作系统两门课的底子,学起JVM的时候会容易许多,因为JVM本质上就是对计算机和操作系统的虚拟,就是一个虚拟机。

Java正是有了这一套虚拟机的支持,才成就了跨平台(一次编译,永久运行)的优势。

这样一来,前言部分我们成功引入JVM,接下来,本文要讲述的重点是JVM自动内存管理,先给出总述:

JVM自动内存管理=分配内存(指给对象分配内存)+回收内存(回收分配给对象的内存)

上面公式告诉我们,JVM自动内存管理分为两块,分配内存和回收内存

二、JVM内存空间与参数设置

2.1 运行时数据区

JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的运行时数据区域。这些运行时数据区包括方法区、堆、虚拟栈、本地方法栈、程序计数器,如图:

让我们一步步介绍,对于运行时数据区,很多博客都是使用顺序介绍的方式,不利于读者对比比较学习,这里笔者以表格的方式呈现:

  程序计数器 Java虚拟机栈  本地方法栈 Java 堆 方法区
存放内容 JVM字节码指令的地址或Undefined(如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器的值则为 (Undefined) 局部变量表、操作数栈、动态链接、方法出口 Native方法(本地方法) 对象实例、数组 类信息、常量、静态变量、即时编译器编译后的代码
用途

字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成

每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。 每一个本地方法的调用执行过程,就对应着一个栈帧从本地方法栈中入栈到出栈的过程。

用于存放对象实例,被对象引用所指向

 
线程共享还是私有 线程私有 线程私有 线程私有 线程间共享 线程间共享
StackOverflowError栈溢出

线程请求的栈深度大于虚拟机所允许的深度。

报错信息:java.lang.StackOverflowError

线程请求的栈深度大于虚拟机所允许的深度。

报错信息:java.lang.StackOverflowError

线程请求的栈深度大于虚拟机所允许的深度。

报错信息:java.lang.StackOverflowError

OutOfMemoryError

内存泄露

如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。

报错信息:java.lang.OutOfMemoryError:unable to create new native thread

如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。

报错信息:java.lang.OutOfMemoryError:unable to create new native thread

如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出该异常。

报错信息:java.lang.OutOfMemoryError: Java heap space

当方法区无法满足内存分配需求,抛出该异常。报错信息:java.lang.OutOfMemoryError: PermGen space
特点 是五个区域中唯一一个没有OutOfMemoryError Java虚拟机栈和本地方法栈都是方法调用栈,不同之处在于是一个是程序员编写的Java方法,一个是自带Native方法。

1、可以位于物理上不连续的空间,但是逻辑上要连续。

2、Java堆又称为CG堆,分为新生区和老年区,新生区又分为Eden区、From Survivor区和To Survivor

又称为Non-Heap,非堆,与Java堆区分开来

让我们对上表继续深入,讲述上表中的StackOverflowError和OutOfMemoryError。

2.2 关于StackOverflowError和OutOfMemoryError

2.2.1 StackOverflowError

运行时数据区中,抛出栈溢出的就是虚拟机栈和本地方法栈,

产生原因:线程请求的栈深度大于虚拟机所允许的深度。因为JVM栈深度是有限的而不是无限的,但是一般的方法调用都不会超过JVM的栈深度,如果出现栈溢出,基本上都是代码层面的原因,如递归调用没有设置出口或者无限循环调用。

解决方法:程序员检查代码是否有无限循环即可。

2.2.2 OutOfMemoryError

容易发生OutOfMemoryError内存溢出问题的内存空间包括:Permanent Generation space和Heap space。

1、第一种java.lang.OutOfMemoryError: PermGen space(方法区抛出)
产生原因:发生这种问题的原意是程序中使用了大量的jar或class,使java虚拟机装载类的空间不够,与Permanent Generation space有关。所以,根本原因在于jar或class太多,方法区堆溢出,则解决方法有两个种,要么增大方法区,要么减少jar、class文件,且看解决方法。

解决方法:
1. 从增大方法区方面入手:

增加java虚拟机中的XX:PermSize和XX:MaxPermSize参数的大小,其中XX:PermSize是初始永久保存区域大小,XX:MaxPermSize是最大永久保存区域大小。

如web应用中,针对tomcat应用服务器,在catalina.sh 或catalina.bat文件中一系列环境变量名说明结束处增加一行:
JAVA_OPTS=" -XX:PermSize=64M -XX:MaxPermSize=128m"
可有效解决web项目的tomcat服务器经常宕机的问题。
2. 从减少jar、class文件入手:

清理应用程序中web-inf/lib下的jar,如果tomcat部署了多个应用,很多应用都使用了相同的jar,可以将共同的jar移到tomcat共同的lib下,减少类的重复加载。

2、第二种OutOfMemoryError:  Java heap space(堆抛出)
产生原因:发生这种问题的原因是java虚拟机创建的对象太多,在进行垃圾回收之间,虚拟机分配的到堆内存空间已经用满了,与Heap space有关。所以,根本原因在于对象实例太多,Java堆溢出,则解决方法有两个种,要么增大堆内存,要么减少对象示例,且看解决方法。

解决方法:
1.从增大堆内存方面入手:

增加Java虚拟机中Xms(初始堆大小)和Xmx(最大堆大小)参数的大小。如:set JAVA_OPTS= -Xms256m -Xmx1024m

2.从减少对象实例入手:

一般来说,正常程序的对象,堆内存时绝对够用的,出现堆内存溢出一般是死循环中创建大量对象,检查程序,看是否有死循环或不必要地重复创建大量对象。找到原因后,修改程序和算法。

3、第三种OutOfMemoryError:unable to create new native thread(Java虚拟机栈、本地方法栈抛出)

产生原因:这个异常问题本质原因是我们创建了太多的线程,而能创建的线程数是有限制的,导致了异常的发生。能创建的线程数的具体计算公式如下:

(MaxProcessMemory - JVMMemory - ReservedOsMemory) / (ThreadStackSize) = Number of threads

MaxProcessMemory 表示一个进程的最大内存

JVMMemory 表示JVM内存

ReservedOsMemory 表示保留的操作系统内存

ThreadStackSize 表示线程栈的大小

在java语言里, 当你创建一个线程的时候,虚拟机会在JVM内存创建一个Thread对象同时创建一个操作系统线程,而这个系统线程的内存用的不是JVMMemory,而是系统中剩下的内存(MaxProcessMemory - JVMMemory - ReservedOsMemory)。由公式得出结论:你给JVM内存越多,那么你能创建的线程越少,越容易发生 java.lang.OutOfMemoryError: unable to create new native thread

解决方法:

1.如果程序中有bug,导致创建大量不需要的线程或者线程没有及时回收,那么必须解决这个bug,修改参数是不能解决问题的。
2.如果程序确实需要大量的线程,现有的设置不能达到要求,那么可以通过修改MaxProcessMemory,JVMMemory,ThreadStackSize这三个因素,来增加能创建的线程数:
MaxProcessMemory 使用64位操作系统
VMMemory 减少 JVMMemory 的分配
ThreadStackSize 减小单个线程的栈大小

2.3 JVM堆内存和非堆内存

2.3.1 堆内存和非堆内存

JVM内存划分为堆内存和非堆内存,堆内存分为年轻代(Young Generation)、老年代(Old Generation),非堆内存就一个永久代(Permanent Generation)。
年轻代又分为Eden和Survivor区。Survivor区由FromSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。
堆内存用途:存放的是对象,垃圾收集器就是收集这些对象,然后根据GC算法回收。
非堆内存用途:永久代,也称为方法区,存储程序运行时长期存活的对象,比如类的元数据、方法、常量、属性等。
在JDK1.8版本废弃了永久代,替代的是元空间(MetaSpace),元空间与永久代上类似,都是方法区的实现,他们最大区别是:元空间并不在JVM中,而是使用本地内存。在后面的实践中,因为笔者使用的是JDK8,所以打印出的GC日志里面就有MetaSpace。

2.3.2 JVM堆内部构型(新生代和老年代)

Jdk8中已经去掉永久区,这里为了与时俱进,不再赘余。

上图演示Java堆内存空间,分为新生代和老年代,分别占Java堆1/3和2/3的空间,新生代中又分为Eden区、Survivor0区、Survivor1区,分别占新生代8/10、1/10、1/10空间。

问题:

1、什么是Java堆?

JVM规范中说到:”所有的对象实例以及数组都要在堆上分配”。

Java堆是垃圾回收器管理的主要区域,百分之九十九的垃圾回收发生在Java堆,另外百分之一发生在方法区,因此又称之为”GC堆”。根据JVM规范规定的内容,Java堆可以处于物理上不连续的内存空间中。

2、为什么Java堆要分为新生代和老年代?

当前JVM对于堆的垃圾回收,采用分代收集的策略。根据堆中对象的存活周期将堆内存分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活。而老年代中存放的对象存活率高。这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

3、为什么新生代要分为Eden区、Survivor0区、Survivor1区?

这是结构与策略相适应的原则,新生代垃圾收集使用的是复制算法(一种垃圾收集算法,Serial收集器、ParNew收集器、Parallel scavenge收集器都是用这种算法),复制算法可以很好的解决垃圾收集的内存碎片问题,但是有一个天然的缺陷,就是要牺牲一半的内存(即任意时刻只有一半内存用于工作),这对于宝贵的内存资源来说是极度奢侈的。新生代在使用复制算法作为其垃圾收集算法的时候,对其做了优化,拿出2/10的新生代的内存作为交换区,称为Survivor0区和Survivor1区(注意:有的博客上称为From Survivor Space和To Survivor Space,这样阐述也是对的,但是容易对初学者形成误导,因为在复制算法中,复制是双向的,没有固定的From和To,这一次是由这一边到另一边,下次就是从另一边到这一边,使用From Survivor Space和To Survivor Space容易让后来学习者误以为复制只能从一边到另一边,当然有的博客中会附加不管从哪边到哪边,起始就是From,终点就是To,即From Survivor Space和To Survivor Space所对应的区循环对调,但是读者不一定想的明白。所以笔者这里使用Survivor0、Survivor1,减少误解)

所以说,新生代在结构上分为Eden区、Survivor0区、Survivor1区,是与其使用的垃圾收集算法(复制算法)相适应的结果。

4、关于永久区Permanent Space?

由于Jdk8中取消了永久区Permanent Space,本文为与时俱进,不再讲述Permanent Space。

2.4 JVM堆参数设置

这些都是和堆内存分配有关的参数,所以我们放在第二部分了,和垃圾收集器有关的参数放在第四部分。

举例:java -Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=16m

2.4.1 JVM重要参数

因为整个堆大小=年轻代大小(新生代大小) + 年老代大小 + 持久代大小,

-Xmn2g:表示年轻代大小为2G。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。

-XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为8,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5,则老年代大小为8G

-XX:SurvivorRatio=8:设置年轻代中Eden区与Survivor区的大小比值。设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10

则Eden:Survivor0:Survivor1=8:1:1

-XX:MaxPermSize=16m:设置持久代大小为16m。

所有整个堆大小=年轻代大小 + 年老代大小 + 持久代大小= 2G+ 8G+ 16M=10G+6M=10246MB

2.4.2 JVM其他参数

-Xmx3550m:设置JVM最大可用内存为3550M。
-Xms3550m:设置JVM促使内存为3550m,此值可以设置与-Xmx相同。

-Xss128k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。更具应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。

关于为什么-xmx与-xms的大小设置为一样的?

首先,在Java堆内存分配中,-xmx用于指定JVM最大分配的内存,-xms用于指定JVM初始分配的内存,所以,-xmx与-xms相等表示JVM初次分配的内存的时候就把所有可以分配的最大内存分配给它(指JVM),这样的做的好处是:

1. 避免JVM在运行过程中、每次垃圾回收完成后向OS申请内存:因为所有的可以分配的最大内存第一个就给它(JVM)了。

2. 延后启动后首次GC的发生时机、减少启动初期的GC次数:因为第一次给它分配了最大的;

3. 尽可能避免使用swap space:swap space为交换空间,当web项目部署到linux上时,有一条调优原则就是“尽可能使用内存而不是交换空间”

4.设置堆内存为不可扩展和收缩,避免在每次GC 后调整堆的大小

影响堆内存扩展与收缩的两个参数

MaxHeapFreeRadio 默认值为70 当xmx值比xms值大,堆可以动态收缩与扩展,这个参数控制当堆空间大于指定比例时会自动收缩,默认表示堆空间大于70%会自动收缩
MinHeapFreeRadio 默认值为40 当xmx值比xms值大,堆可以动态收缩与扩展,这个参数控制当堆空间小于指定比例时会自动扩展,默认表示堆空间小于40%会自动扩展

由上表可知,堆内存默认是自动扩展和收缩的,但是有一个前提条件,就是到xmx比xms大的时候,当我们将xms设置为和xmx一样大,堆内存就不可扩展和收缩了,即整个堆内存被设置为一个固定值,避免在每次GC 后调整堆的大小。

附加:在Java非堆内存分配中,一般是用永久区内存分配:

JVM 使用**-XX:PermSize** 设置非堆内存初始值,由 XX:MaxPermSize 设置最大非堆内存的大小。 

2.5 从日志看JVM(开发实践)

这里了设置GC日志关联的类和将GC日志打印

如程序所述,申请了10MB的空间,allocation1 2MB+allocation2 2MB+allocation3 2MB+allocation4 4MB=10MB

接下来我们开始阅读GC日志,这里笔者以自己电脑上打印的GC日志为例,讲述阅读GC日志的方法:

heap表示堆,即下面的日志是对JVM堆内存的打印;

因为使用的是jdk8,所以默认使用ParallelGC收集器,也就是在新生代使用Parallel Scavenge收集器,老年代使用ParallelOld收集器

PSYoungGen 表示使用Parallel scavenge收集器作为年轻代收集器,ParOldGen表示使用Parallel old收集器作为老年代收集器,即笔者电脑上默认是使用Parallel scavenge+Parallel old收集器组合。

其中,PSYoungGen总共38400K(37.5MB),被使用了13568K(13.25MB),PSYoungGen又分为Eden Space  33280K(32.5MB) 被使用了40% 13MB,from space 5120K(5MB)和to space 5120K(5MB),这就是一个eden区和两个survivor区。

此处注意,因为使用的是jdk8,所以没有永久区了,只有MetaSpace,见上图。

三、HotSpot VM

3.1  HotSpot VM相关知识

问题一:什么是HotSpot虚拟机?HotSpot VM的前世今生?

回答一:HotSpot VM是由一家名为“Longview Technologies”的公司设计的一款虚拟机,Sun公司收购Longview Technologies公司后,HotSpot VM成为Sun主要支持的VM产品,Oracle公司收购Sun公司后,即在HotSpot的基础上,移植JRockit的优秀特性,将HotSpot VM与JRockit VM整合到一起。

问题二:HotSpot VM有何优点?

回答二:HotSpot VM的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译和OSR(栈上替换)编译动作。 通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更多的代码优化技术,输出质量更高的本地代码。

问题三:HotSpot VM与JVM是什么关系?

回答三:今天的HotSpot VM,是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。

3.2 HotSpot VM的两个实现与查看本机HotSpot

HotSpot VM包括两个实现,不同的实现适合不同的场景:

Java HotSpot Client VM:通过减少应用程序启动时间和内存占用,在客户端环境中运行应用程序时可以获得最佳性能。此经过专门调整,可缩短应用程序启动时间和内存占用,使其特别适合客户端环境。此jvm实现比较适合我们平时用作本地开发,平时的开发不需要很大的内存。

Java HotSpot Server VM:旨在最大程度地提高服务器环境中运行的应用程序的执行速度。此jvm实现经过专门调整,可能是特别调整堆大小、垃圾回收器、编译器那些。用于长时间运行的服务器程序,这些服务器程序需要尽可能快的运行速度,而不是快速启动时间。

只要电脑上安装jdk,我们就可以看到hotspot的具体实现:

四、JVM内存回收

我们知道,Java中是没有析构函数的,既然没有析构函数,那么如何回收对象呢,答案是自动垃圾回收。Java语言的自动回收机制可以使程序员不用再操心对象回收问题,一切都交给JVM就好了。那么JVM又是如何做到自动回收垃圾的呢,且看本节,本节分为两个部分——垃圾收集算法和垃圾收集器,其中,收集算法是内存回收的理论,而垃圾回收器是内存回收的实践。

4.1 垃圾收集算法(内存回收理论)

4.1.1 标记-清除算法

标记-清除算法分为两个阶段,“标记”和“清除”,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。该方法有两个不足:一个是效率问题,标记和清除两个过程的效率都不会太高;一个是空间问题,标记清除后产生大量不连续的内存碎片,这些内存空间碎片可能会导致以后程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作,如果很容易出现这样的空间碎片多、无法找到大的连续空间的情况,垃圾收集就会较为频繁

4.1.2 复制算法

为了解决“标记-清除算法”的效率问题,一种复制算法产生了,它将当前可用内存按容量划分为大小相等的两块,每次只使用其中一块。当一块的内存用完了,就将还活着的对象复制到另一块上面,然后再把已使用的内存空间一次清除掉。这样使得每次都对整个半区进行内存回收,内存分配时就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。

这种算法处理内存碎片的核心在于将整个半块中活的的对象复制到另一整个半块上面去,所以称为复制算法。

附:关于复制算法的改进

复制算法合理的解决了内存碎片问题,但是却要以牺牲一半的宝贵内存为代价,这是非常让人心疼的。令人愉快地是,现代虚拟机中,早就有了关于复制算法的改进:

对于Java堆中新生代中的对象来说,99%的对象都是“朝升夕死”的,就是说很多的对象在创建出来后不久就会死掉了,所有我们可以大胆一点,不需要按照1:1的比例来划分内存空间,而是将新生代的内存划分为一块较大的Eden区(一般占新生代8/10的大小)和两块较小的Survivor区(用于复制,一般每块占新生代1/10的大小,两块占新生代2/10的大小)。当回收时,将Eden区和Survivor里面当前还活着的对象全部都复制到另一块Survivor中(关于另一个块Survivor是否会溢出的问题,答案是不会,这里将新生代90%的容量里的对象复制到10%的容量里面,确实是有风险的,但是JVM有一种内存的分配担保机制,即当目的Survivor空间不够,会将多出来的对象放到老年代中,因为老年代是足够大的),最后清理Eden区和源Survivor区的空间。这样一来,每次新生代可用内存空间为整个新生代90%,只有10%的内存被浪费掉,

正是因为这一特性,现代虚拟机中采用复制算法来回收新生代,如Serial收集器、ParNew收集器、Parallel scavenge收集器均是如此。

4.1.3 标志-整理算法

对于新生代来说,由于具有“99%的对象都是朝生夕死的”这一特点,所以我们可以大胆的使用10%的内存去存放90%的内存中活着的对象,即使是目的Survivor的容量不够,也可以将多余的存放到老年代中(担保机制),所有对于新生代,我们使用复制算法是比较好的(Serial收集器、ParNew收集器、Parallel scavenge收集器)。

但是对于老年代,没有大多数对象朝生夕死这一特点,如果使用复制算法就要浪费一半的宝贵内存,所有我们用另一种办法来处理它(指老年代)——标志-整理算法

标记-整理算法分为两个阶段,“标记”和“整理”,

标记:首先标记出所有需要回收的对象(和标记-清除算法一样);

整理:在标记完成后让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

4.1.4 分代收集算法

当前商业虚拟机都是的垃圾收集都使用“分代收集”算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采取最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量对象存活,就是使用复制算法,这样只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象的存活率高、没有额外空间对其分配担保(新生代复制算法如果目的Survivor容量不够会将多余对象放到老年代中,这就是老年代对新生代的分配担保),必须使用“标记-清除算法”或“标记-整理算法”来回收。

四种常用算法优缺点比较、用途比较

  优点 缺点 用途/具体收集器实现
标记-清除算法 实现简单

1、标记和清除效率不高;

2、因为清除带来内存碎片问题,导致后面的大内存块越来越少,垃圾收集提前

cms收集器
复制算法 解决了内存碎片问题 1、每次只能使用一半的内存,意味着需要牺牲一半的宝贵内存 Serial收集器、ParNew收集器、Parallel scavenge收集器
标志-整理算法 充分使用内存空间,解决内存碎片问题 标记和整理效率不高 serial old收集器、Parallel old收集器
分代收集算法 结合实际(新生代对象存活时间短,老年代对象存活时间长),使用不同的收集算法 这是一种综合收集策略,一般来说,新生代使用复制算法,老年代是用“标记-清除算法”或“标志-整理算法”

4.2 垃圾收集器(内存回收实践)

有了上面的垃圾回收算法,就有了很多的垃圾回收器。对于垃圾回收器,很少有表格对比,笔者以表格对比的方式呈现:

 

单线程

or多线程

新生代or老年代 基于的收集算法 备注
Serial收集器 单线程 新生代 复制算法

优点:简单

缺点:Stop the world,垃圾收集时要停掉所有其他线程

常用组合:Serial + serial old  新生代和老年代都是单线程,简单

ParNew收集器(是Serial收集器的多线程版本) 多线程 新生代 复制算法

优点:相对于Serial收集器,使用了多线程

缺点:除了多线程,其他所有和Serial收集器一样

常用组合:ParNew+ serial old  新生代多线程,老年代单线程,简单(新生代ParNew收集器仅仅是Serial收集器的多线程版本,所有该组合相对于Serial + serial old 只是新生代是多线程而已,其余不变)

Parallel scavenge收集器(吞吐量优先收集器) 多线程 新生代 复制算法

设计目标:尽可能达到一个可控制的吞吐量

吞吐量=运行用户代码时间/(运行用户代码时间+来及收集时间)

优点:吞吐量高,可以高效率地利用CPU时间,尽快完成程序的计算任务,适合后台运算

缺点:没有太大缺陷

常用组合:Parallel scavenge + Parallel old  该组合完成吞吐量优先虚拟机,适用于后台计算

serial old收集器(是Serial收集器的老年代版本) 单线程 老年代 标记-整理算法

优点:简单

缺点:Stop the world,垃圾收集时要停掉所有其他线程

常用组合:Serial + serial old  新生代和老年代都是单线程,简单

Parallel old收集器(是Parallel scavenge收集器的老年代版本) 多线程 老年代 标记-整理算法

优点:吞吐量高,可以高效率地利用CPU时间,尽快完成程序的计算任务,适合后台运算

缺点:没有太大缺陷

常用组合:Parallel scavenge + Parallel old  该组合完成吞吐量优先虚拟机,适用于后台计算

cms收集器(并发低停顿收集器) 多线程 老年代 标记-清除算法

优点:停顿时间短,适合与用户交互的程序

四个步骤:

初始标记 CMS initial mark

并发标记 CMS concurrent mark

重新标记 CMS remark

并发清除 CMS concurrent sweep

常用组合:cms收集器  完成响应时间短虚拟机,适用于用户交互

G1收集器 多线程 新生代+老年代 标记-整理算法

面向服务端的垃圾回收器。

特点:并行与并发、分代收集、空间整合、可预测的停顿

四个步骤:

初始标记 Initial Marking

并发标记 Concurrent Marking

最终筛选 Final Marking 

筛选回收 Live Data Counting and Evacuation

常用组合:G1收集器   面向服务端的垃圾回收器

注意:G1收集器的收集算法加粗了,这里做出说明,G1收集器从整体上来看是基于“标记-整理”算法实现的收集器,从局部(两个region之间)上看来是基于“复制”算法实现的。

从上表可以得到的收集常用组合包括:

常用组合1:Serial + serial old  新生代和老年代都是单线程,简单

常用组合2:ParNew+ serial old  新生代多线程,老年代单线程,简单

常用组合3:Parallel scavenge + Parallel old  该组合完成吞吐量优先虚拟机,适用于后台计算

常用组合4:cms收集器  完成响应时间短虚拟机,适用于用户交互

常用组合5:G1收集器   面向服务端的垃圾回收器

4.2.1 常用组合1:Serial + serial old  新生代和老年代都是单线程,简单

附:图上有一个safepoint,译为安全点(有的博客上写成了savepoint,是错误的,至少是不准确的),这个safepoint干什么的呢?如何确定这个safepoint的位置?

这个safepoint是干什么的?

safepoint的定义是“A point in program where the state of execution is known by the VM”,译为程序中一个点就是虚拟机所知道的一个执行状态。

JVM中safepoint有两种,分别为GC safepoint、Deoptimization safepoint:

GC safepoint:用在垃圾收集操作中,如果要执行一次GC,那么JVM里所有需要执行GC的Java线程都要在到达GC safepoint之后才可以开始GC;

Deoptimization safepoint:如果要执行一次deoptimization,那么JVM里所有需要执行deoptimization的Java线程都要在到达deoptimization safepoint之后才可以开始deoptimize

我们上图中的safepoint自然是GC safepoint,所以上图中的两个safepoint都是指执行GC线程前的状态。

对于上图的理解是(很多博客上都有这种运行示意图,但是没有加上解释,笔者这里加上):

1、多个用户线程(图中是四个)要开始执行新生代GC操作,所以都要达到GC safepoint点,先到的要等待晚到的,图中都达到了;

2、四个线程都执行新生代的GC操作,因为使用的是Serial收集器,所以是基于复制算法的单线程GC,而且要Stop the world,所以只有GC线程在执行,四个用户线程都停止了。

3、新生代GC操作完成,四个线程继续执行,过了一会儿,要开始执行老年代的GC操作了,所以四个线程都要再次达到GC safepoint点,先到的要等待晚到的,图中都达到了;

4、四个线程都执行老年代的GC操作,因为使用的是Serial Old收集器,所以是基于标志-整理算法的单线程GC,而且要Stop the world,所以只有GC线程在执行,四个用户线程都停止了。

5、老年代GC操作完成,四个线程继续执行。

4.2.2 常用组合2:ParNew+ serial old   新生代多线程,老年代单线程,简单

该组合中新生代ParNew收集器仅仅是Serial收集器的多线程版本,所有该组合相对于Serial + serial old 只是新生代是多线程而已,其余不变

对于上图的理解是(很多博客上都有这种运行示意图,但是没有加上解释,笔者这里加上):

1、多个用户线程(图中是四个)要开始执行新生代GC操作,所以都要达到GC safepoint点,先到的要等待晚到的,图中都达到了;

2、四个线程都执行新生代的GC操作,因为使用的是Parnew收集器,所以是基于复制算法的多线程GC(注意,这里的多线程GC,是指多个GC线程并发,用户线程还是要停止的)所以还是要Stop the world,所以只有GC线程在执行,四个用户线程都停止了。

3、新生代GC操作完成,四个线程继续执行,过了一会儿,要开始执行老年代的GC操作了,所以四个线程都要再次达到GC safepoint点,先到的要等待晚到的,图中都达到了;

4、四个线程都执行老年代的GC操作,因为使用的是Serial Old收集器,所以是基于标志-整理算法的单线程GC,而且要Stop the world,所以只有GC线程在执行,四个用户线程都停止了。

5、老年代GC操作完成,四个线程继续执行。

4.2.3 常用组合3:Parallel scavenge + Parallel old  该组合完成吞吐量优先虚拟机,适用于后台计算

对于上图的理解是:

1、多个用户线程(图中是四个)要开始执行新生代GC操作,所以都要达到GC safepoint点,先到的要等待晚到的,图中都达到了;

2、四个线程都执行新生代的GC操作,因为使用的是Parallel scavenge收集器,所以是基于复制算法的多线程GC(注意,这里的多线程GC,是指多个GC线程并发,用户线程还是要停止的)所以只有GC线程在执行,四个用户线程都停止了。

3、新生代GC操作完成,四个线程继续执行,过了一会儿,要开始执行老年代的GC操作了,所以四个线程都要再次达到GC safepoint点,先到的要等待晚到的,图中都达到了;

4、四个线程都执行老年代的GC操作,因为使用的是Parallel Old收集器,所以是基于标志-整理算法的多线程GC,(注意,这里的多线程GC,是指多个GC线程并发,用户线程还是要停止的)所以只有GC线程在执行,四个用户线程都停止了。

5、老年代GC操作完成,四个线程继续执行。

4.2.4 常用组合4:cms收集器  完成响应时间短虚拟机,适用于用户交互

对于上图的理解是:

CMS收集包括四个步骤:初始标记、并发标记、重新标记、并发清除

  是否需要stop the world,停止用户线程 单个GC线程运行or多个GC线程运行
初始标记 需要 单个GC线程运行
并发标记 不需要 多个GC线程运行
重新标记 需要 多个GC线程运行
并发清除 不需要 多个GC线程运行

1、多个用户线程(图中是四个)要开始执行新生代GC操作,所以都要达到GC safepoint点,先到的要等待晚到的,图中都达到了;

2、四个线程都执行GC操作,因为使用的是CMS收集器,第一步骤是初始标记,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,GC的标记阶段需要stop the world,让所有Java线程挂起,这样JVM才可以安全地来标记对象。所以只有“初始标记”在执行,四个用户线程都停止了。初始标记完成后,达到第二个GC safepoint,图中达到了;

3、开始执行并发标记,并发标记是GCRoot开始对堆中的对象进行可达性分析,找出存活的对象,并发标记可以与用户线程一起执行,并发标记完成后,所有线程达到下一个GC safepoint,图中达到了;

4、开始执行重新标记,重新标记是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那部分标记记录,

重新标记完成后,所有线程达到下一个GC safepoint,图中达到了;

5、开始执行并发清理,并发清理可以与用户线程一起执行,并发清理完成后,所有线程达到下一个GC safepoint,图中达到了;

6、开始重置线程,就是对刚才并发标记操作的对象,图中是线程3(注意:重置线程针对的是并发标记的线程,没有被并发标记的线程不需要重置线程操作),重置操作线程3的时候,与其他三个用户线程无关,它们可以一起执行。

注意:由于cms收集器整个过程中耗时最长的并发标记和并发清除过程中,GC线程都可以与用户线程一起工作,初始标记和重新标记时间忽略不计,所以,从总体上来说,cms收集器的内存回收过程与用户线程是并发执行的,所以上表中cms为多线程收集器。

4.2.5 常用组合5:G1收集器  面向服务端的垃圾回收器

1、什么是G1?

G1就是Gabage-First,它将整个Java堆划分为多个大小相等的独立区域,即Region,虽然还保留新生代和老年代的概念,但新生代和老年代已不再物理隔离,它们都是一部分Region的集合。

G1收集器的底层原理:G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次依据允许的收集时间,优先收集回收价值最大的Region。正是这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以获取尽可能高的效率。

G1收集器运行示意图如下:

对于上图的理解是:

G1收集包括四个步骤:初始标记、并发标记、最终筛选、筛选回收

1、多个用户线程(图中是四个)要开始执行新生代GC操作,所以都要达到GC safepoint点,先到的要等待晚到的,图中都达到了;

2、开始执行初始标记,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一个阶段用户程序并发标记时,能在正确可用的Region上创建新对象,整个标记阶段需要stop the world,让所有Java线程挂起,这样JVM才可以安全地来标记对象。所以只有“初始标记”在执行,四个用户线程都停止了。初始标记完成后,达到第二个GC safepoint,图中达到了;

3、开始执行并发标记,并发标记是GCRoot开始对堆中的对象进行可达性分析,找出存活的对象,并发标记可以与用户线程一起执行,并发标记完成后,所有线程(GC线程、用户线程)达到下一个GC safepoint,图中达到了;

4、开始执行最终标记,最终标记是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那部分标记记录,最终标记完成后,所有线程达到下一个GC safepoint,图中达到了;

5、开始执行筛选回收,筛选回归首先对各个Region的回收价值和成本排序, 根据用户期待的GC停顿时间来制定回收计划,筛选回收过程中,因为停顿用户线程将大幅提高收集效率,所以一般筛选回归是停止用户线程的,筛选回归完成后,所有线程达到下一个GC safepoint,图中达到了;

6、G1收集器收集结束,继续并发执行用户线程。

4.3 垃圾收集器常用参数

(笔者这里加上idea上如何使用这些参数,这些是垃圾收集器的参数,所以这里放到第四部分,在本文第五部分内存分配我们会用到)

参数 idea中使用方式 描述
UseSerialGC

VM Options:

-XX:+UseSerialGC

虚拟机运行在Client模式下的默认值,打开此开关之后,使用Serial+Serial Old的收集器组合进行内存回收
UseParNewGC

VM Options: 

-XX:+UseParNewGC

打开此开关之后,使用ParNew+ Serial Old的收集器组合进行内存回收
UseConcMarkSweepGC

VM Options: 

-XX:+UseConcMarkSweepGC

打开此开关之后,使用ParNew + CMS+ Serial Old的收集器组合进行内存回收。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用
UseParallelGC

VM Options: 

-XX:+UseParallelGC

虚拟机运行在Server模式下的默认值,打开此开关之后,使用Parallel + Scavenge + Serial Old(PS MarkSweep)的收集器组合进行内存回收
UseParallelOldGC

VM Options: 

-XX:UseParallelOldGC

打开此开关后,使用Parallel Scavenge + Parallel Old 的收集器组合进行内存回收
SurvivorRatio

VM Options: 

-XX:SurvivorRatio=8

新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Survivor=8:1
PretenureSizeThreshold

VM Options: 

-XX:PretenureSizeThreshold=3145728

表示大于3MB都到老年代中去

直接晋升到老年代的对象大小,设置这个参数后,这个参数以字节B为单位大于这个参数的对象将直接在老年代中分配
MaxTenuringThreshold

VM Options: 

-XX:MaxTenuringThreshold=2

表示经历两次Minor GC,就到老年代中去

晋升到老年代的对象年龄,每个对象在坚持过一次Minor GC之后,年龄就增加1,当超过这个参数值就进入到老年代
UseAdaptiveSizePolicy

VM Options: 

-XX:+UseAdaptiveSizePolicy

动态调整Java堆中各个区域的大小以及进入老年代的年龄
HandlePromotionFailure jdk1.8下,HandlePromotionFailure会报错,Unrecongnized VM option 是否允许分配担保失败,即老年代的剩余空间不足应应对新生代的整个Eden区和Survivor区的所有对象存活的极端情况
ParallelGCThreads

VM Options: 

-XX:ParallelGCThreads=10

设置并行GC时进入内存回收线程数
GCTimeRadio

VM Options: 

-XX:GCTimeRadio=99

GC占总时间的比率,默认值是99,即允许1%的GC时间,仅在使用Parallel Scavenge收集器时生效
MaxGCPauseMillis

VM Options:

-XX:MaxGCPauseMillis=100

设置GC的最大停顿时间,仅在使用Parallel Scavenge收集器时生效
CMSInitiatingOccupanyFraction

VM Options:

-XX:CMSInitiatingOccupanyFraction=68

设置CMS收集器在老年代空间被使用多少后触发垃圾收集,默认值68%,仅在使用CMS收集器时生效
UseCMSCompactAtFullCollection

VM Options: 

-XX:+UseCMSCompactAtFullCollection

设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片的整理,仅在使用CMS收集器时生效
CMSFullGCsBeforeCompaction

VM Options:

-XX:CCMSFullGCsBeforeCompaction=10

设置CMS收集在进行若干次垃圾收集后再启动一次内存碎片整理,仅在使用CMS收集器时生效

五、JVM内存分配

这部分的内容借鉴《深入理解Java虚拟机》一书,有改动。

附:What is minorGC?  What is Major GC(Full GC)?

新生代GC(Minor GC):发生在新生代的垃圾收集动作,因为Java对象大多具有朝生夕灭的特性,所有Minor GC非常频繁,一般回收速度较快。

老年代GC(Major GC/Full GC):发生在老年代的GC,出现了major GC,经常会伴随一个MinorGC(但是不绝对),Major GC速度一般比Minor GC慢10倍。

5.1 对象优先在Eden上分配

5.1.1 设置VM Options

-XX:+PrintGCDetails             //打印GC日志
-Xms20M                              //初始堆大小为20M
-Xmx20M        //最大堆大小为20M
-Xmn10M           //年轻代大小为10M,则老年代大小=堆大小20M-年轻代大小10M=10M
-XX:SurvivorRatio=8     //年轻代 Eden:Survivor=8  则Eden为8M  Survivor0为1M  Survivor1为1M
-XX:+UseSerialGC       //笔者使用的jdk8默认为Parallel scavenge+Parallel old收集器组合,书上使用Serial+Serial Old的收集器组合,这里设置好

5.1.2 程序输出(给出附加解释)

第一步:可以看到,当分配6M内存时,全部都在Eden区,没有任何问题,说明JVM优先在Eden区上分配对象

第二步:因为年轻代只有9M,剩下1M是给To Survivor用的,已经使用了6M,现在申请4M, 就会触发Minor GC,将6M的存活的对象放到目的survivor中去,但是放不下,因为目的survivor只有1M空间,所以分配担保到老年代中去,然后将4M对象放到Eden区中。所以,最后的结果是 Eden区域使用了4096KB 4M 老年代中使用了6M 这里form space占用57%可以忽略不计。

5.2 大对象直接进入老年代(使用-XX:PretenureSizeThreshold参数设置)

5.2.1  设置VM Options

-XX:+PrintGCDetails             //打印GC日志
-Xms20M                              //初始堆大小为20M
-Xmx20M        //最大堆大小为20M
-Xmn10M           //年轻代大小为10M,则老年代大小=堆大小20M-年轻代大小10M=10M
-XX:SurvivorRatio=8     //年轻代 Eden:Survivor=8  则Eden为8M  Survivor0为1M  Survivor1为1M
-XX:+UseSerialGC       //笔者使用的jdk8默认为Parallel scavenge+Parallel old收集器组合,书上使用Serial+Serial Old的收集器组合,这里设置好
-XX:PretenureSizeThreshold=3145728    // 单位是字节 3145728/1024/1024=3MB  大于3M的对象直接进入老年代

5.2.2 程序输出(给出附加解释)

5.3 长期存活的对象应该进入老年代(使用-XX:MaxTenuringThreshold参数设置)

5.3.1 设置VM Options

-XX:+PrintGCDetails             //打印GC日志
-Xms20M                              //初始堆大小为20M
-Xmx20M        //最大堆大小为20M
-Xmn10M           //年轻代大小为10M,则老年代大小=堆大小20M-年轻代大小10M=10M
-XX:SurvivorRatio=8     //年轻代 Eden:Survivor=8  则Eden为8M  Survivor0为1M  Survivor1为1M
-XX:+UseSerialGC       //笔者使用的jdk8默认为Parallel scavenge+Parallel old收集器组合,书上使用Serial+Serial Old的收集器组合,这里设置好
-XX:MaxTenuringThreshold=1   //表示经历一次Minor GC,就到老年代中去

5.3.2 程序输出(给出附加解释)

第一步骤:只分配allocation1 allocation2,不会产生任何Minor GC,对象都在Eden区中

第二步骤:分配allocation3,产生Minor GC,allocation2移入老年区

第三步骤:allocation3再次分配,allocation1也被送入老年区,老年区里有allocation1 allocation2

六、小结

本文讲述JVM自动内存管理(包括内存回收和内存),前言部分从操作系统引入JVM,第二部分介绍JVM空间结构(运行时数据区、堆内存和非堆内存),第三部分介绍HotSpot虚拟机,第四部分和第五部分分别介绍自动内存回收和自动内存分配的原理实现。

天天打码,天天进步!

JVM(一)——JVM自动内存管理       https://blog.csdn.net/qq_36963950/article/details/103997117

JVM(二)——JVM执行子系统,针丝千缕解析.class文件     https://blog.csdn.net/qq_36963950/article/details/104058823

JVM(三)——JVM优化(编译时优化+运行时优化)与JVM性能调优https://blog.csdn.net/qq_36963950/article/details/104087310

JVM(四)——JVM高效并发,一点一滴解析多线程并发的底层实  https://blog.csdn.net/qq_36963950/article/details/104103203

发布了177 篇原创文章 · 获赞 31 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/qq_36963950/article/details/103997117