JVM学习笔记

来源:互联网 发布:轩辕剑入灵进阶数据 编辑:程序博客网 时间:2024/06/10 04:04

一.Java的内存模型
程序计数器:一块比较小的内存区域,指向当前所执行的字节码。如果线程正在执行一个Java方法,这个计数器记录正在执行的虚拟机字节码指令的地址,如果执行的是Native方法,这个计算器值为空。
Java虚拟机栈:线程私有的,其生命周期和线程一致,每个方法执行都创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。
本地方法栈:与虚拟机栈功能类似,只不过虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为使用到的Native方法服务。
Java堆:是虚拟机管理内存中最大的一块,被所有线程共享,该区域用于存放对象实例,几乎所有的对象都在该区域分配。Java堆是内存回收的主要区域,从内存回收角度看,由于现在的收集器大都采用分代收集算法,所以Java还可以细分为:新生代和老年代,再细分一点的话可以分为Eden空间,From Survicor空间,To Survicor空间等,根据Java虚拟机规范规定,Java堆可以处于物理上不连续的空间,只要逻辑上是连续的就行。
方法区:与Java堆一样,是各个线程所共享的,用于存储已被虚拟机加载类信息,常量,静态常量,即时编译器编译后的代码等数据。
运行时常量池:运行时常量池是方法区的一部分,Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用。运行期间可以将新的常量放入常量池中,用得比较多的就是String类的intern()方法,当一个String实例调用intern时,Java查找常量池中是否有相同的Unicode的字符串常量,若有,则返回其引用;若没有,则在常量池中增加一个Unicode等于该实例字符串并返回它的引用。

二.JVM的内存模型
JVM堆内存被分为两部分,年轻代和老年代
年轻代:
年轻代是所有新对象产生的地方,当年轻代内存空间被用完时,就会触发垃圾回收。这个垃圾回收叫作Minor GC。年轻代被分为3个部分,Eden区和两个Survivor区。
年轻代空间的要点:
大多数新建的对象都位于Eden区。
当Eden区被对象填满时,就会执行Minor GC。并把所有存活下来的对象转移到其中一个Survicor区。
Minor GC同样会检查存活下来的对象,并把它们转移到另一个Survivor区,这样在一段时间内,总会有一个空的Servivor区。
经过多次GC周期后,任然存活下来的对象会被转移到年老代内存空间。通常这是在年轻代有资格提升到年老代前通过设定年龄阈值来完成。
年老代
年老代内存里包含了长期存活的对象和经过多次Minor GC后依然存活下来的对象。通常会在老年代内存被占满时进行垃圾回收,老年代的垃圾收集叫做Major GC。Major GC会花费更多的时间。
Stop the World事件
所有的垃圾收集都是“Stop the World”事件,因为所有的应用线程都会停下来知道操作完成
因为年轻代里的对象都是一些临时对象,执行Minor GC非常快,所以应用不会收到(“Stop the World”)影响
Java堆内存开关

垃圾对象如何确定
Java堆中存放这几乎所有的对象实例,垃圾收集在对堆进行回收前,首先需要确定哪些对象还“活着”,哪些已经“死亡”,也就是不会被任何途径使用的对象。
引用计数法
引用技术法实现简单,效率较高,在大部分情况下是一个不错的算法。其原理是:给对象添加一个引用计数器。每当有一个地方引用该对象时,计数器加1,当引用失效时,计数器减1,当计数器值为0时表示该对象不再被使用。需要注意的是:引用计数法很难解决对象之间相互循环引用的话题,主流Java虚拟机没有选用引用计数法来管理内存。
可达性分析算法
这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径成为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。如图所示,对象Object 5 ,Objetc 6 ,Object 7 虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。

在Java语言中,可作为GC Roots的对象包括下面几种:
虚拟机栈中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI引用的对象。
对象生存还是死亡
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记路程:如果对象在进行可达性分析后发现没有GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的调教是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。程序中可以通过覆盖finalize()来一场“惊心动魄”的自我拯救过程,但是,这只有一次机会。
引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。
在JDK1.2之后,Java对引用的概念进行了补充
将引用分为强引用,软引用,弱引用,虚引用4种,这4中引用强度依次逐渐减弱。
强引用
就是指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的的对象。
软引用
是用来描述一些还有用但并非必需的对象,对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常
弱引用
也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉纸杯弱引用关联的对象。
虚引用
也成为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是内在这个对象被收集器回收时收到一个系统通知。
五.典型的垃圾回收算法
1.Mark-Sweep(标记-清除)算法
这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:

从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。
2.Copying(复制)算法
为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:

这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。
很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。
3.Mark-Compact(标记-整理)算法
为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:

4.Generation Collection(分代收集)算法
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。
  目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间(一般为8:1:1),每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。
  而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。

JDK(Java Development Kit) 是 Java 语言的软件开发工具包(SDK)。JDK 物理存在,是 programming tools、JRE 和 JVM 的一个集合

JRE(Java Runtime Environment)Java 运行时环境,JRE 物理存在,主要由Java API 和 JVM 组成,提供了用于执行 java 应用程序最低要求的环境。

JVM
JVM(Java Virtual Machine) 是一种软件实现,执行像物理机程序的机器(即电脑)。
本来,Java被设计基于从物理机器分离实现WORA( 写一次,随处运行 )的虚拟机上运行,虽然这个目标已经几乎被遗忘。
JVM 并不是专为 Java 所实现的运行时,实际上只要有其他编程语言的编译器能生成正确 Java bytecode 文件,则这个语言也能实现在JVM上运行。
因此,JVM 通过执行 Java bytecode 可以使 java 代码在不改变的情况下运行在各种硬件之上。

JVM特点:
基于堆栈的虚拟机 :最流行的计算机体系结构,如英特尔X86架构和ARM架构上运行基于寄存器 。 但是,JVM是基于栈的。
符号引用 :除了基本类型以外的数据(类和接口)都是通过符号来引用,而不是通过显式地使用内存地址来引用。
垃圾收集 :一个类的实例是由用户明确创建的代码和垃圾回收自动销毁。
通过明确界定的基本数据类型的保证平台的独立性 :传统的语言,如C / C ++根据平台有不同的int型的大小。 JVM中明确规定了基本数据类型,以保持它的兼容性和保证平台的独立性。
网络字节顺序 :Java class文件用网络字节码顺序来进行存储:为了保证和小端的Intel x86架构以及大端的RISC系列的架构保持无关性,JVM使用用于网络传输的网络字节顺序,也就是大端。
Java bytecode
为了实现WORA,JVM使用Java字节码,java(用户语言)和机器语言之间的中间语言。该Java字节码是部署Java代码的最小单位。

JVM 用来做什么
JVM 中执行以下操作:
● 加载代码
● 验证代码
● 执行代码
● 提供运行环境

JVM 提供定义了:
● 存储区
● 类文件格式
● 寄存器组
● 垃圾回收堆
● 致命错误报告等

JVM 生命周期
● 启动:任何一个拥有main函数的class都可以作为JVM实例运行的起点
● 运行:main函数为起点,程序中的其他线程均有它启动,包括daemon守护线程和non-daemon普通线程。daemon是JVM自己使用的线程比如GC线程,main方法的初始线程是non-daemon。
● 消亡:所有线程终止时,JVM实例结束生命。

JVM 的整体架构
先看一下 java 代码执行过程

● Class Loader
● Excution Engine
● Runtime Data Areas

Class Loader
类加载器负责加载程序中的类型(类和接口),并赋予唯一的名字。

JDK 默认提供了三种 ClassLoader

关系:
1.Bootstrp loader 是在Java虚拟机启动后初始化的。
2.Bootstrp loader 负责加载 ExtClassLoader,并且将 ExtClassLoade r的父加载器设置为 Bootstrp loader。
3.Bootstrp loader 加载完 ExtClassLoader 后,就会加载 AppClassLoader,并且将 AppClassLoader 的父加载器指定为 ExtClassLoader。
Class Loader实现负责加载Bootstrp loaderC++%JAVA_HOME%/jre/lib,-Xbootclasspath参数指定的路径以及%JAVA_HOME%/jre/classes中的类ExtClassLoaderJava%JAVA_HOME%/jre/lib/ext,此路径下的所有classes目录以及java.ext.dirs系统变量指定的路径中类库AppClassLoaderJavaclasspath所指定的位置的类或者是jar文档,它也是Java程序默认的类加载器

双亲委托模型
Java中ClassLoader的加载采用了双亲委托机制,采用双亲委托机制加载类的时候采用如下的几个步骤:
1.当前ClassLoader首先从自己已经加载的类中查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类。
2.当前classLoader的缓存中没有找到被加载的类的时候,委托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到bootstrp ClassLoader.
3.当所有的父类加载器都没有加载的时候,再由当前的类加载器加载,并将其放入它自己的缓存中,以便下次有加载请求的时候直接返回。

为什么使用双亲委托模型——ClassLoader 隔离问题
每个类装载器都有一个自己的命名空间用来保存已装载的类。当一个类装载器装载一个类时,它会通过保存在命名空间里的类全局限定名(Fully Qualified Class Name)进行搜索来检测这个类是否已经被加载了。
大家觉得一个运行程序中有没有可能同时存在两个包名和类名完全一致的类?
JVM 及 Dalvik 对类唯一的识别是 ClassLoader id + PackageName + ClassName,所以一个运 行程序中是有可能存在两个包名和类名完全一致的类的。并且如果这两个”类”不是由一个ClassLoader 加载,是无法将一个类的示例强转为另外一个类的,这就是 ClassLoader 隔离。
双亲委托是 ClassLoader 问题的一种解决方案,也是 Android 差价化开发和热修复的基础。

类装载器特点
Java提供了动态加载特性;他会在运行时的第一次引用到一个class的时候对它进行装载(Loading)、链接(Linking)和初始化(Initialization),而不是在编译时进行。不同的JVM的实现不同,本文所描述的内容均只限于Hotspot Jvm。JVM的类装载器负责动态装载,Java的类装载器有如下几个特点:
层级结构:Java里的类装载器被组织成了有父子关系的层级结构。Bootstrap类装载器是所有装载器的父亲。
代理模式: 基于层级结构,类的代理可以在装载器之间进行代理。当装载器装载一个类时,首先会检查它在父装载器中是否进行了装载。如果上层装载器已经装载了这个类,这个类会被直接使用。反之,类装载器会请求装载这个类
可见性限制:一个子装载器可以查找父装载器中的类,但是一个父装载器不能查找子装载器里的类。
不允许卸载:类装载器可以装载一个类但是不可以卸载它,不过可以删除当前的类装载器,然后创建一个新的类装载器装载。

过程
加载(Loading)是这样一个过程,找到代表这个类的class文件或根据特定的名字找到接口类型,然后读取到一个字节数组中。接着,这些字节会被解析检验它们是否代表一个Class对象并包含正确的major、minor版本信息。直接父类的类和接口也会被加载进来。这些操作一旦完成,类或者接口对象就从二进制表示中创建出来了。
链接(Linking)是检验类或接口并准备类型和父类接口的过程。链接过程包含三步:校验(Verifying)、准备(Preparing)、部分解析(Optionally resolving)。

验证:这是类装载中最复杂的过程,并且花费的时间也是最长的。任务是确保导入类型的准确性,验证阶段做的检查,运行时不需要再做,虽然减慢加了载速度,但是避免了多次检查。
准备:分配一个结构用来存储类信息,这个结构中包含了类中定义的成员变量,方法和接口的信息。
解析:可选阶段,把这个类的常量池中的所有的符号引用改变成直接引用。如果不执行,符号解析要等到字节码指令使用这个引用时才会进行

初始化(Initialization)把类中的变量初始化成合适的值。执行静态初始化程序,把静态变量初始化成指定的值。

执行引擎(Execution Engine)
通过类装载器装载的,被分配到JVM的运行时数据区的字节码会被执行引擎执行。执行引擎以指令为单位读取 Java 字节码。它就像一个 CPU 一样,一条一条地执行机器指令。每个字节码指令都由一个1字节的操作码和附加的操作数组成。执行引擎取得一个操作码,然后根据操作数来执行任务,完成后就继续执行下一条操作码。
不过 Java 字节码是用一种人类可以读懂的语言编写的,而不是用机器可以直接执行的语言。因此,执行引擎必须把字节码转换成可以直接被 JVM 执行的语言。字节码可以通过以下两种方式转换成合适的语言。

解释器:一条一条地读取,解释并且执行字节码指令。因为它一条一条地解释和执行指令,所以它可以很快地解释字节码,但是执行起来会比较慢。这是解释执行的语言的一个缺点。字节码这种“语言”基本来说是解释执行的。
即时(Just-In-Time)编译器:即时编译器被引入用来弥补解释器的缺点。执行引擎首先按照解释执行的方式来执行,然后在合适的时候,即时编译器把整段字节码编译成本地代码。然后,执行引擎就没有必要再去解释执行方法了,它可以直接通过本地代码去执行它。执行本地代码比一条一条进行解释执行的速度快很多。编译后的代码可以执行的很快,因为本地代码是保存在缓存里的。
Java 字节码是解释执行的,但是没有直接在 JVM 宿主执行原生代码快。为了提高性能,Oracle Hotspot 虚拟机会找到执行最频繁的字节码片段并把它们编译成原生机器码。编译出的原生机器码被存储在非堆内存的代码缓存中。通过这种方法(JIT),Hotspot 虚拟机将权衡下面两种时间消耗:将字节码编译成本地代码需要的额外时间和解释执行字节码消耗更多的时间。

运行时数据区
JVM 运行时数据结构图:

PC寄存器(PC Register)
也叫程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的信号指示器。
每一条JVM线程都有自己的PC寄存器
在任意时刻,一条 JVM 线程只会执行一个方法的代码。该方法称为该线程的当前方法(Current Method)
如果该方法是 java 方法,那PC寄存器保存 JVM 正在执行的字节码指令的地址
如果该方法是 native,那 PC 寄存器的值是 undefined。
此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

JVM 栈(Java Virtual Machine Stack)
与 PC 寄存器一样,java 虚拟机栈(Java Virtual Machine Stack)也是线程私有的。每一个JVM线程都有自己的java虚拟机栈,这个栈与线程同时创建,它的生命周期与线程相同,用来保存栈帧。JVM 只会在 JVM 栈上进行 push 和 pop 的操作。
JVM stack 可以被实现成固定大小,也可以根据计算动态扩展。
如果采用固定大小的JVM stack设计,那么每一条线程的JVM Stack容量应该在线程创建时独立地选定。JVM实现应该提供调节JVM Stack初始容量的手段。
如果采用动态扩展和收缩的JVM Stack方式,应该提供调节最大、最小容量的手段。

JVM 栈异常情况
StackOverflowError:当线程请求分配的栈容量超过JVM允许的最大容量时抛出
OutOfMemoryError:如果JVM Stack可以动态扩展,但是在尝试扩展时无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的虚拟机栈时抛出。

栈帧(stack frame)
栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间分配在 Java 虚拟机栈之中,每一个栈帧都有自己的局部变量表(Local Variables)、操作数栈(Operand Stack)和指向当前方法所属的类的运行时常量池的引用。

局部变量数组(Local variable array)
每个栈帧内部都包含一组称为局部变量表(Local Variables)的变量列表。栈帧中局部变量表的长度由编译期决定。
局部变量使用索引来进行定位访问,第一个局部变量的索引值为零,局部变量的索引值是从零至小于局部变量表最大容量的所有整数。
Java 虚拟机使用局部变量表来完成方法调用时的参数传递,当一个方法被调用的时候,它的参数将会传递至从 0 开始的连续的局部变量表位置上。特别地,当一个实例方法被调用的时候,第 0 个局部变量一定是用来存储被调用的实例方法所在的对象的引用(即 Java 语言中的“this”关键字)。后续的其他参数将会传递至从 1 开始的连续的局部变量表位置上。

操作数栈(Operand stack)
每一个栈帧内部都包含一个称为操作数栈(Operand Stack)的后进先出(Last-In-First-Out,LIFO)栈。栈帧中操作数栈的长度由编译期决定。
操作数栈所属的栈帧在刚刚被创建的时候,操作数栈是空的。Java 虚拟机提供一些字节码指令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据和把操作结果重新入栈。在方法调用的时候,操作数栈也用来准备调用方法的参数以及接收方法返回结果。

动态链接(Dynamic Linking)
每个栈帧都有一个运行时常量池的引用。这个引用指向栈帧当前运行方法所在类的常量池。通过这个引用支持动态链接(dynamic linking)。

本地方法栈(Native method stack)
Java虚拟机可能会使用到传统的栈来支持native方法(使用Java语言以外的其它语言编写的方法)的执行,这个栈就是本地方法栈(Native Method Stack)
如果JVM不支持native方法,也不依赖与传统方法栈的话,可以无需支持本地方法栈。
如果支持本地方法栈,则这个栈一般会在线程创建的时候按线程分配。
异常情况:
StackOverflowError:如果线程请求分配的栈容量超过本地方法栈允许的最大容量时抛出
OutOfMemoryError:如果本地方法栈可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的本地方法栈,那Java虚拟机将会抛出一个OutOfMemoryError异常。

方法区(Method area)
在Java虚拟机中,被加载类型的信息都保存在方法区中。包括类型信息(Type Information)和方法列表(Method Tables)。方法区是所有线程共享的,所以访问方法区信息的方法必须是线程安全的。如果你有两个线程都去加载一个叫Lava的类,那只能由一个线程被容许去加载这个类,另一个必须等待。
它是在JVM启动的时候创建的。
存储了每一个类的结构信息,例如运行时常量池(Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法。
方法区的容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。
方法区在实际内存空间中可以是不连续的。
Java虚拟机实现应当提供给程序员或者最终用户调节方法区初始容量的手段,对于可以动态扩展和收缩方法区来说,则应当提供调节其最大、最小容量的手段。
是否对方法区进行垃圾回收对JVM的实现是可选的。

Java 方法区异常:
OutOfMemoryError: 如果方法区的内存空间不能满足内存分配请求,那Java虚拟机将抛出一个OutOfMemoryError异常。

运行时常量池(Runtime constant pool)
运行时常量池是每一个类或接口的常量池(Constant_Pool)的运行时表现形式,它包括了若干种常量:编译器可知的数值字面量到必须运行期解析后才能获得的方法或字段的引用。简而言之,当一个方法或者变量被引用时,JVM通过运行时常量区来查找方法或者变量在内存里的实际地址。
运行时常量池是方法区的一部分。每一个运行时常量池都分配在JVM的方法区中,在类和接口被加载到JVM后,对应的运行时常量池就被创建。
在创建类和接口的运行时常量池时,可能会遇到的异常:
OutOfMemoryError:当创建类和接口时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大内存空间后就会抛出OutOfMemoryError

堆(Heap)
在 JVM 中,堆(heap)是可供各条线程共享的运行时内存区域,也是供所有类实例和数据对象分配内存的区域。
Java堆载虚拟机启动的时候就被创建,堆中储存了各种对象,这些对象被自动管理内存系统(Automatic Storage Management System,也即是常说的“Garbage Collector(垃圾回收器)”)所管理。这些对象无需、也无法显示地被销毁。
Java堆的容量可以是固定大小,也可以随着需求动态扩展,并在不需要过多空间时自动收缩。
Java堆所使用的内存不需要保证是物理连续的,只要逻辑上是连续的即可。
JVM实现应当提供给程序员调节Java 堆初始容量的手段,对于可动态扩展和收缩的堆来说,则应当提供调节其最大和最小容量的手段。
Java 堆异常:
OutOfMemoryError:如果实际所需的堆超过了自动内存管理系统能提供的最大容量时抛出。

JVM-GC

垃圾收集的算法分析
既然是要进行自动GC,那必然会有相应的策略,而这些策略解决了哪些问题呢,粗略的来说,主要有以下几点。
1、哪些对象可以被回收。
2、何时回收这些对象。
3、采用什么样的方式回收。

具体算法:
1、引用计数法(Reference Counting Collector)
引用计数法是唯一没有使用根集的垃圾回收的法,该算法使用引用计数器来区分存活对象和不再使用的对象。一般来说,堆中的每个对象对应一个引用计数器。当每一次创建一个对象并赋给一个变量时,引用计数器置为1。当对象被赋给任意变量时,引用计数器每次加1当对象出了作用域后(该对象丢弃不再使用),引用计数器减1,一旦引用计数器为0,对象就满足了垃圾收集的条件。

基于引用计数器的垃圾收集器运行较快,但是有个致命缺陷:那就是对于循环引用的对象无法进行回收。

2.根搜索算法
大多数垃圾回收算法使用了根集(root set)这个概念;所谓根集就是正在执行的Java程序可以访问的引用变量的集合(包括局部变量、参数、类变量),程序可以使用引用变量访问对象的属性和调用对象的方法。垃圾回收首先需要确定从根开始哪些是可达的和哪些是不可达的,从根集可达的对象都是活动对象,它们不能作为垃圾被回收,这也包括从根集间接可达的对象。而根集通过任意路径不可达的对象符合垃圾收集的条件,应该被回收。下面介绍几个常用的算法。说到GC roots(GC根),在JAVA语言中,可以当做GC roots的对象有以下几种:1、虚拟机栈中的引用的对象。2、方法区中的类静态属性引用的对象。3、方法区中的常量引用的对象。4、本地方法栈中JNI的引用的对象。
第一和第四种都是指的方法的本地变量表,第二种表达的意思比较清晰,第三种主要指的是声明为final的常量值

图 3.0 标记(marking)对象
首先,垃圾回收器将某些特殊的对象定义为GC根对象。所谓的GC根对象包括:
(1)虚拟机栈中引用的对象(栈帧中的本地变量表);
(2)方法区中的常量引用的对象;
(3)方法区中的类静态属性引用的对象;
(4)本地方法栈中JNI(Native方法)的引用对象。
(5)活跃线程。

接下来,垃圾回收器会对内存中的整个对象图进行遍历,它先从GC根对象开始,然后是根对象引用的其它对象,比如实例变量。回收器将访问到的所有对象都标记为存活。
存活对象在上图中被标记为蓝色。当标记阶段完成了之后,所有的存活对象都已经被标记完了。其它的那些(上图中灰色的那些)也就是GC根对象不可达的对象,也就是说你的应用不会再用到它们了。这些就是垃圾对象,回收器将会在接下来的阶段中清除它们。

关于标记阶段有几个关键点是值得注意的:
(1)开始进行标记前,需要先暂停应用线程,否则如果对象图一直在变化的话是无法真正去遍历它的。暂停应用线程以便JVM可以尽情地收拾家务的这种情况又被称之为安全点(Safe Point),这会触发一次Stop The World(STW)暂停。触发安全点的原因有许多,但最常见的应该就是垃圾回收了。
(2)暂停时间的长短并不取决于堆内对象的多少也不是堆的大小,而是存活对象的多少。因此,调高堆的大小并不会影响到标记阶段的时间长短。
(3)在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:
1.如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize()方法(可看作析构函数,类似于OC中的dealloc,Swift中的deinit)。当对象没有覆盖finalize()方法,或finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。
2.如果该对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的finalize()方法最多只会被系统自动调用一次),稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中让该对象重新引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。
(4)实际上GC判断对象是否可达看的是强引用。
当标记阶段完成后,GC开始进入下一阶段,删除不可达对象。

3、tracing算法(Tracing Collector) 标记-清除
标记—清除算法是最基础的收集算法,为了解决引用计数法的问题而提出。它使用了根集的概念,它分为“标记”和“清除”两个阶段:首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的根搜索算法中判定垃圾对象的标记过程。
优点:不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效。
缺点:(1)标记和清除过程的效率都不高。(这种方法需要使用一个空闲列表来记录所有的空闲区域以及大小。对空闲列表的管理会增加分配对象时的工作量。如图4.1所示。)。(2)标记清除后会产生大量不连续的内存碎片。虽然空闲区域的大小是足够的,但却可能没有一个单一区域能够满足这次分配所需的大小,因此本次分配还是会失败(在Java中就是一次OutOfMemoryError)不得不触发另一次垃圾收集动作。如图4.2所示。

图 4.0 标记—清除算法

图4.1 标记—清除算法

4、compacting算法(Compacting Collector) 标记-整理
该算法标记的过程与标记—清除算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。在基于Compacting算法的收集器的实现中,一般增加句柄和句柄表。
优点:(1)经过整理之后,新对象的分配只需要通过指针碰撞便能完成(Pointer Bumping),相当简单。(2)使用这种方法空闲区域的位置是始终可知的,也不会再有碎片的问题了。
缺点:GC暂停的时间会增长,因为你需要将所有的对象都拷贝到一个新的地方,还得更新它们的引用地址。
算法示意图:

图4.2 标记—整理算法

图4.3 标记—整理算法

5、copying算法(Coping Collector) 复制
该算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它将内存按容量分为大小相等的两块,每次只使用其中的一块(对象面),当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面(空闲面),然后再把已使用过的内存空间一次清理掉。
复制算法比较适合于新生代(短生存期的对象),在老年代(长生存期的对象)中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如标记—整理算法。一种典型的基于Coping算法的垃圾回收是stop-and-copy算法,它将堆分成对象区和空闲区,在对象区与空闲区的切换过程中,程序暂停执行。
优点:(1)标记阶段和复制阶段可以同时进行。(2)每次只对一块内存进行回收,运行高效。(3)只需移动栈顶指针,按顺序分配内存即可,实现简单。(4)内存回收时不用考虑内存碎片的出现(得活动对象所占的内存空间之间没有空闲间隔)。
缺点:需要一块能容纳下所有存活对象的额外的内存空间。因此,可一次性分配的最大内存缩小了一半。

算法示意图:

图4.4 Copying算法

图4.4 Copying算法

它们的共同点主要有以下两点。
1、三个算法都基于根搜索算法去判断一个对象是否应该被回收,而支撑根搜索算法可以正常工作的理论依据,就是语法中变量作用域的相关内容。因此,要想防止内存泄露,最根本的办法就是掌握好变量作用域,而不应该使用前面内存管理杂谈一章中所提到的C/C++式内存管理方式。
2、在GC线程开启时,或者说GC过程开始时,它们都要暂停应用程序(stop the world)。

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

新生代GC(Minor GC/Scavenge GC):发生在新生代的垃圾收集动作。因为Java对象大多都具有朝生夕灭的特性,因此Minor GC非常频繁(不一定等Eden区满了才触发),一般回收速度也比较快。在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此可选用复制算法来完成收集。
老年代GC(Major GC/Full GC):发生在老年代的垃圾回收动作。Major GC,经常会伴随至少一次Minor GC。由于老年代中的对象生命周期比较长,因此Major GC并不频繁,一般都是等待老年代满了后才进行Full GC,而且其速度一般会比Minor GC慢10倍以上。另外,如果分配了Direct Memory,在老年代中进行Full GC时,会顺便清理掉Direct Memory中的废弃对象。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清除算法或标记—整理算法来进行回收。
新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从Eden到Survivor,最后到老年代。

垃圾收集器分为三类:
串行搜集器(serial collector):它只有一条GC线程,且就像前面说的,它在运行的时候需要暂停用户程序(stop the world)。
并行搜集器(parallel collector):它有多条GC线程,且它也需要暂停用户程序(stop the world)。
并发搜集器(concurrent collector):它有一条或多条GC线程,且它需要在部分阶段暂停用户程序(stop the world),部分阶段与用户程序并发执行。

串行搜集器的实现:serial(用于新生代,采用复制算法)、serial old(用于年老代,采用标记/整理算法)
并行搜集器的实现:ParNew(用于新生代,采用复制算法)、Parallel Scavenge(用于新生代,采用复制算法)、Parallel old(用于年老代,采用标记/整理算法)
并发搜集器的实现:concurrent mark sweep[CMS](用于年老代,采用标记/清除算法)

1.Serial(SerialMSC)(Copying算法)
Serial收集器是最基本最古老的收集器,它是一个单线程收集器,并且在它进行垃圾收集时,必须暂停所有用户线程。Serial收集器是针对新生代的收集器,采用的是Copying算法。

2.Serial Old (标记—整理算法)
Serial Old收集器是针对老年代的收集器,采用的是Mark-Compact算法。它的优点是实现简单高效,但是缺点是会给用户带来停顿。

3.ParNew (Copying算法)
ParNew收集器是新生代收集器,Serial收集器的多线程版本。使用多个线程进行垃圾收集,在多核CPU环境下有着比Serial更好的表现。

4.Parallel Scavenge (Copying算法)
Parallel Scavenge收集器是一个新生代的多线程收集器(并行收集器),它在回收期间不需要暂停其他用户线程,其采用的是Copying算法,该收集器与前两个收集器有所不同,它主要是为了达到一个可控的吞吐量。追求高吞吐量,高效利用CPU。吞吐量一般为99%。 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。

5.Parallel Old(ParallelMSC)(标记—整理算法)
Parallel Old是Parallel Scavenge收集器的老年代版本(并行收集器),使用多线程和Mark-Compact算法。吞吐量优先。

6.CMS (标记—整理算法)
CMS(Current Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它是一种并发收集器,采用的是Mark-Sweep算法。高并发、低停顿,追求最短GC回收停顿时间,CPU占用比较高。响应时间快,停顿时间短,多核CPU 追求高响应时间的选择。

7.G1
G1收集器是当今收集器技术发展最前沿的成果,它是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。
G1垃圾回收器适用于堆内存很大的情况,他将堆内存分割成不同的区域,并且并发的对其进行垃圾回收。G1也可以在回收内存之后对剩余的堆内存空间进行压缩。并发扫描标记垃圾回收器在STW情况下压缩内存。G1垃圾回收会优先选择第一块垃圾最多的区域。

新生代收集器使用的收集器:Serial、PraNew、Parallel Scavenge。
老年代收集器使用的收集器:Serial Old、Parallel Old、CMS。

事实上,这六种垃圾搜集器只有六种选择,因为有的垃圾搜集器由于具体实现的方式等一系列原因无法在一起工作,如下图。

针对上图,红的就是串行搜集器,绿的是并行搜集器,唯一一个黄的是并发搜集器。上面三个是新生代的搜集器,下面三个是年老代的搜集器。两者之间有连线,则表示两者可以配合工作。

与垃圾回收时间有关的两个函数
1. System.gc()方法
命令行参数监视垃圾收集器的运行:使用System.gc()可以不管JVM使用的是哪一种垃圾回收的算法,都可以请求Java的垃圾回收。在命令行中有一个参数-verbosegc可以查看Java使用的堆内存的情况,它的格式如下:
java -verbosegc classfile
需要注意的是,调用System.gc()也仅仅是一个请求(建议)。JVM接受这个消息后,并不是立即做垃圾回收,而只是对几个垃圾回收算法做了加权,使垃圾回收操作容易发生,或提早发生,或回收较多而已。

  1. finalize()方法
    概述:在JVM垃圾回收器收集一个对象之前,一般要求程序调用适当的方法释放资源。但在没有明确释放资源的情况下,Java提供了缺省机制来终止该对象以释放资源,这个方法就是finalize()。它的原型为:
    protected void finalize() throws Throwable
    在finalize()方法返回之后,对象消失,垃圾收集开始执行。原型中的throws Throwable表示它可以抛出任何类型的异常。
    由于,finalize函数没有自动实现链式调用,我们必须手动的实现,因此finalize函数的最后一个语句通常是 super.finalize()。通过这种方式,我们可以实现从下到上实现finalize的调用,即先释放自己的资源,然后再释放父类的资源。根据Java语言规范,JVM保证调用finalize函数之前,这个对象是不可达的,但是JVM不保证这个函数一定会被调用。另外,规范还保证finalize函数最多运行一次。
    其一、GC为了能够支持finalize函数,要对覆盖这个函数的对象作很多附加的工作。
    其二、在finalize运行完成之后,该对象可能变成可达的,GC还要再检查一次该对象是否是可达的。因此,使用 finalize会降低GC的运行性能。
    其三、由于GC调用finalize的时间是不确定的,因此通过这种方式释放资源也是不确定的。
    通常,finalize用于一些不容易控制、并且非常重要资源的释放,例如一些I/O的操作,数据的连接。这些资源的释放对整个应用程序是非常关键的。在这种情况下,程序员应该以通过程序本身管理(包括释放)这些资源为主,以finalize函数释放资源方式为辅,形成一种双保险的管理机制,而不应该仅仅依靠finalize来释放资源。

意义:之所以要使用finalize(),是存在着垃圾回收器不能处理的特殊情况。假定你的对象(并非使用new方法)获得了一块“特殊”的内存区域,由于垃圾回收器只知道那些显示地经由new分配的内存空间,所以它不知道该如何释放这块“特殊”的内存区域,那么这个时候Java允许在类中定义一个finalize()方法。
特殊的区域例如:1)由于在分配内存的时候可能采用了类似 C语言的做法,而非JAVA的通常new做法。这种情况主要发生在native method中,比如native method调用了C/C++方法malloc()函数系列来分配存储空间,但是除非调用free()函数,否则这些内存空间将不会得到释放,那么这个时候就可能造成内存泄漏。但是由于free()方法是在C/C++中的函数,所以finalize()中可以用本地方法来调用它。以释放这些“特殊”的内存空间。2)又或者打开的文件资源,这些资源不属于垃圾回收器的回收范围。
换言之,finalize()的主要用途是释放一些其他做法开辟的内存空间,以及做一些清理工作。因为在Java中并没有提够像“析构”函数或者类似概念的函数,要做一些类似清理工作的时候,必须自己动手创建一个执行清理工作的普通方法,也就是override Object这个类中的finalize()方法。比如:销毁通知。
一旦垃圾回收器准备好释放对象占用的存储空间,首先会去调用finalize()方法进行一些必要的清理工作。只有到下一次再进行垃圾回收动作的时候,才会真正释放这个对象所占用的内存空间。
JAVA里的对象并非总会被垃圾回收器回收。1 对象可能不被垃圾回收,2 垃圾回收并不等于“析构”,3 垃圾回收只与内存有关。也就是说,并不是如果一个对象不再被使用,是不是要在finalize()中释放这个对象中含有的其它对象呢?不是的。因为无论对象是如何创建的,垃圾回收器都会负责释放那些对象占有的内存。

触发主GC(Garbage Collector)的条件
1)当应用程序空闲时,即没有应用线程在运行时,GC会被调用。因为GC在优先级最低的线程中进行,所以当应用忙时,GC线程就不会被调用,但以下条件除外。
2)Java堆内存不足时,GC会被调用。当应用线程在运行,并在运行过程中创建新对象,若这时内存空间不足,JVM就会强制地调用GC线程,以便回收内存用于新的分配。若GC一次之后仍不能满足内存分配的要求,JVM会再进行两次GC作进一步的尝试,若仍无法满足要求,则 JVM将报“out of memory”的错误,Java应用将停止。

减少GC开销的措施
(1)不要显式调用System.gc()
此函数建议JVM进行主GC,虽然只是建议而非一定,但很多情况下它会触发主GC,从而增加主GC的频率,也即增加了间歇性停顿的次数。

(2)尽量减少临时对象的使用
临时对象在跳出函数调用后,会成为垃圾,少用临时变量就相当于减少了垃圾的产生,从而延长了出现上述第二个触发条件出现的时间,减少了主GC的机会。

(3)对象不用时最好显式置为Null
一般而言,为Null的对象都会被作为垃圾处理,所以将不用的对象显式地设为Null,有利于GC收集器判定垃圾,从而提高了GC的效率。

(4)尽量使用StringBuffer,而不用String来累加字符串
由于String是固定长的字符串对象,累加String对象时,并非在一个String对象中扩增,而是重新创建新的String对象,如Str5=Str1+Str2+Str3+Str4,这条语句执行过程中会产生多个垃圾对象,因为对次作“+”操作时都必须创建新的String对象,但这些过渡对象对系统来说是没有实际意义的,只会增加更多的垃圾。避免这种情况可以改用StringBuffer来累加字符串,因StringBuffer是可变长的,它在原有基础上进行扩增,不会产生中间对象。

(5)能用基本类型如Int,Long,就不用Integer,Long对象
基本类型变量占用的内存资源比相应对象占用的少得多,如果没有必要,最好使用基本变量。

(6)尽量少用静态对象变量
静态变量属于全局变量,不会被GC回收,它们会一直占用内存。

(7)分散对象创建或删除的时间
集中在短时间内大量创建新对象,特别是大对象,会导致突然需要大量内存,JVM在面临这种情况时,只能进行主GC,以回收内存或整合内存碎片,从而增加主GC的频率。集中删除对象,道理也是一样的。它使得突然出现了大量的垃圾对象,空闲空间必然减少,从而大大增加了下一次创建新对象时强制主GC的机会。

0 0
原创粉丝点击