五、JVM知识详解

1. JVM内存模型

Java 虚拟机在执⾏ Java 程序的过程中会把它管理的内存划分成若⼲个不同的数据区域。JDK1.8 和之前的版本略有不同,下⾯会介绍到。

img

①程序计数器:线程私有

记录当前线程所执行到的字节码的行号。

每个线程都有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

唯一没有OutOfMemoryError情况的内存区域。

它的⽣命周期随着线程的创建⽽创建,随着线程的结束⽽死亡。

程序计数器在哪些地方用到了?

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

Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。

②Java虚拟机栈:线程私有

描述的是 Java ⽅法执⾏的内存模型,每个Java方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、⽅法出⼝等信息。

每个Java方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

Java 虚拟机栈也是线程私有的,每个线程都有各⾃的Java虚拟机栈,⽽且随着线程的创建⽽创建,随着线程的死亡⽽死亡。

局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引⽤(reference类型,它不同于对象本身,可能是⼀个指向对象起始地址的引⽤指针,也可能是指向⼀个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

这些数据类型在局部变量表中的存储空间是以局部变量槽来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。

局部变量表所需的内存空间在编译期间完成分配。当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。

StackOverFlowError:若Java虚拟机栈的内存⼤⼩不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最⼤深度的时候,就抛出StackOverFlowError异常。(典型的场景有:递归调用和死循环)

OutOfMemoryError:若 Java 虚拟机栈的内存⼤⼩允许动态扩展,且当线程请求栈时内存⽤完了,⽆法再动态扩展了,此时抛出OutOfMemoryError异常。

在栈上分配对象

大多数对象都在堆上分配内存空间,但是由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配,标量替换优化手段已经导致对象在堆上分配不是那么绝对了。

③本地方法栈:线程私有

本地方法栈和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java ⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合⼆为⼀。本地⽅法被执⾏的时候,在本地⽅法栈也会创建⼀个栈帧,⽤于存放该本地⽅法的局部变量表、操作数栈、动态链接、出⼝信息。

⽅法执⾏完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和OutOfMemoryError 两种异常。

④堆:线程共享

Java 虚拟机所管理的内存中最⼤的⼀块,Java 堆是所有线程共享的⼀块内存区域,在虚拟机启动时创建。

此内存区域的唯⼀⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在这⾥分配内存。

堆分区

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。

从垃圾回收的⻆度,由于现在收集器基本都采⽤分代垃圾收集算法,所以Java堆还可以细分为:新⽣代和⽼年代。

新生代再细致⼀点有:Eden空间、From Survivor、To Survivor空间等。

进⼀步划分的⽬的是更好地回收内存,或者更快地分配内存。

img

上图所示的 eden区、s0区、s1区都属于新⽣代,tentired 区属于⽼年代。⼤部分情况,对象都会⾸先在 Eden 区域分配,在⼀次新⽣代垃圾回收后,如果对象还存活,则会进⼊ s0 或者 s1,并且对象的年龄还会加 1(Eden区i>Survivor 区后对象的初始年龄变为1),当它的年龄增加到⼀定程度(默认为15岁),就会被晋升到⽼年代中。对象晋升到⽼年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

⑤方法区:线程共享

它⽤于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

⽅法区也被称为永久代。很多⼈都会分不清⽅法区和永久代的关系,为此我也查阅了⽂献。

永久代是对方法区的一种实现方式。方法区是一种定义,永久代是一种实现。

运⾏时常量池

运⾏时常量池是⽅法区的⼀部分。Class ⽂件中除了有类的版本、字段、⽅法、接⼝等描述信息外,还有常量池信息(⽤于存放编译期⽣成的各种字⾯量和符号引⽤)

既然运⾏时常量池时⽅法区的⼀部分,⾃然受到⽅法区内存的限制,当常量池⽆法再申请到内存时会抛出 OutOfMemoryError 异常。

JDK1.7及之后版本的 JVM 已经将运⾏时常量池从⽅法区中移了出来,在 Java 堆(Heap)中开辟了⼀块区域存放运⾏时常量池。

直接内存

直接内存并不是虚拟机运⾏时数据区的⼀部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使⽤。⽽且也可能导致 OutOfMemoryError 异常出现。

2. JDK1.7和1.8 内存模型的区别

JDK1.7JVM内存模型:

线程私有:Java虚拟机栈、本地方法栈、程序计数器

线程共享:方法区、堆

JDK1.8JVM内存模型:

JDK1.8与1.7最大的区别是1.8将永久代(方法区)取消,取而代之的是元空间。

JDK1.7方法区是由永久代实现的,JDK1.8方法区是由元空间实现的,元空间属于本地内存,所以元空间的大小受本地内存的限制。

JDK1.7 JVM内存模型

img

JDK1.8 JVM内存模型

img

3. Java内存模型(JMM)

Java内存模型规定所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本的拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

主内存:主要对应Java堆中的对象实例数据部分。

工作内存:对应于虚拟机栈中的部分区域。

img

4. Java对象的创建过程

类加载检查: 虚拟机遇到⼀条 new 指令时,⾸先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。

分配内存: 在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存。对象所需的内存⼤⼩在类加载完成后便可确定,为对象分配空间的任务等同于把⼀块确定⼤⼩的内存从 Java 堆中划分出来。分配⽅式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。

初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。

设置对象头: 初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。

⑤****执⾏ init ⽅法: 在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看,对象创建才刚开始, ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说,执⾏ new 指令之后会接着执⾏ ⽅法,把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。

[注意]:执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。

5. 堆内存中对象分配的基本策略

①对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC

②大对象直接进入老年代

所谓的大对象是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。可通过配置设置令操作设置值的对象直接在老年代分配。

这样做的目的避免在Eden区以及两个Survivor区之间发生大量的内存复制(新生代采用复制算法收集内存)。

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

虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的华,将被移动到Survivor空间中,并且对象的年龄设置为 1.对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁)就将会被晋升到老年代中。年龄阈值可通过配置调整-XX:MaxTenuringThreshold参数。

④动态年龄判定

虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold参数才能晋升到老年代。

如果在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到配置参数中要求的年龄。

⑤空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。

如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的。如果小于或者设置不允许冒险,这时要改为进行一次Full GC。

6. 如何判断对象是否死亡

堆中⼏乎放着所有的对象实例,对堆垃圾回收前的第⼀步就是要判断哪些对象已经死亡(即不能再被任何途径使⽤的对象)。

①引用计数法

给对象中添加⼀个引⽤计数器,每当有⼀个地⽅引⽤它,计数器就加1;当引⽤失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使⽤的。

(但是,引用计数法很难解决对象之间相互循环引用的问题)

②可达性分析算法

通过一些列的称为GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots之间没有任何引用链相连,则证明这个对象是不可用的。

7. 判断不可达一定会被回收吗

即使在可达性分析算法判断不可达的对象,也并非是“非死不可的”。

如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已经被调用过,finalize()方法都不会执行,该对象将会被回收。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列中,然后由Finalizer线程去执行。GC将会对F-Queue中的对象进行第二次标记,如果对象在finalize()方法中重新与引用链上的任何一个对象建立关联,那么在第二次标记时将会被移除“即将回收”的集合,否则该对象将会被回收。

(比如:把自己(this关键字)赋值给某个类变量(static修饰)或者对象的成员变量)

img

8. GC Roots 有哪些

1.在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

2.在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。

3.在方法区中常量引用的对象,譬如字符串常量池里的引用。

4.在本地方法栈中JNI(即通常所说的Native方法)引用的对象。

5.Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

6.所有被同步锁(synchronized关键字)持有的对象。

7.反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

9. 强引用、软引用、弱引用、虚引用

⽆论是通过引⽤计数法判断对象引⽤数量,还是通过可达性分析法判断对象的引⽤链是否可达,判定对象的存活都与“引⽤”有关。

JDK1.2之前,Java中引⽤的定义很传统:如果reference类型的数据存储的数值代表的是另⼀块内存的起始地址,就称这块内存代表⼀个引⽤。

JDK1.2以后,Java对引⽤的概念进⾏了扩充,将引⽤分为强引⽤、软引⽤、弱引⽤、虚引⽤四种(引⽤强度逐渐减弱)

强引⽤(StrongReference)

强引用就是指类似于“Object obj = new Object()”这类的引用。被强引用关联的对象,垃圾回收器绝不会回收它。当内存空间不⾜时,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终⽌,也不会靠随意回收具有强引⽤的对象来解决内存不⾜问题。

软引⽤(SoftReference)

被软引用关联的对象只有在内存空间不足时才会被回收。软引⽤可⽤来实现内存敏感的⾼速缓存。软引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果软引⽤所引⽤的对象被垃圾回收,JAVA虚拟机就会把这个软引⽤加⼊到与之关联的引⽤队列中。在JDK1.2后,提供了SoftReference

弱引⽤(WeakReference)

弱引⽤与软引⽤的区别在于:只具有弱引⽤的对象拥有更短暂的⽣命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,⼀旦发现了只具有弱引⽤的对象,不管当前内存空间⾜够与否,都会回收它的内存。不过,由于垃圾回收器是⼀个优先级很低的线程, 因此不⼀定会很快发现那些只具有弱引⽤的对象。弱引⽤可以和⼀个引⽤队(ReferenceQueue)联合使⽤,如果弱引⽤所引⽤的对象被垃圾回收,Java虚拟机就会把这个弱引⽤加⼊到与之关联的引⽤队列中。

**虚引⽤(**PhantomReference)

"虚引⽤"顾名思义,就是形同虚设,与其他⼏种引⽤都不同,虚引⽤并不会决定对象的⽣命周期。如果⼀个对象仅持有虚引⽤,那么它就和没有任何引⽤⼀样,在任何时候都可能被回收为一个对象设置虚引用的唯一目的就是这个对象被垃圾回收器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。

虚引⽤与软引⽤和弱引⽤的⼀个区别在于:虚引⽤必须和引⽤队列(ReferenceQueue)联合使⽤。当垃圾回收器准备回收⼀个对象时,如果发现它还有虚引⽤,就会在回收对象的内存之前,把这个虚引⽤加⼊到与之关联的引⽤队列中。程序可以通过判断引⽤队列中是 否已经加⼊了虚引⽤,来了解被引⽤的对象是否将要被垃圾回收。程序如果发现某个虚引⽤已经被加⼊到引⽤队列,那么就可以在所引⽤的对象的内存被回收之前采取必要的⾏动。

(软引用是在垃圾回收之后把引用加入到引用队列中,虚引用是在垃圾回收之前把引用加入到引用队列中)

特别注意,在程序设计中⼀般很少使⽤弱引⽤与虚引⽤,使⽤软引⽤的情况较多,这是因为软引⽤可以加速JVM对垃圾内存的回收速度,可以维护系统的运⾏安全,防⽌内存溢出(OutOfMemory)等问题的产⽣。

10. 垃圾收集算法

①标记-清除算法

算法分为“标记”和“清除”阶段:⾸先标记出所有需要回收的对象,在标记完成后统⼀回收所有被标记的对象。它是最基础的收集算法,后续的算法都是对其不⾜进⾏改进得到。这种垃圾收集算***带来两个明显的问题:

\1. 效率问题,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量的增长而降低。

\2. 空间问题(标记清除后会产⽣⼤量不连续的碎⽚,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作)

img

②标记-复制算法

标记-复制算法简称“复制算法

为了解决效率问题,“复制”收集算法出现了。

它可以将内存分为⼤⼩相同的两块,每次使⽤其中的⼀块。当这⼀块的内存使⽤完后,就将还存活的对象复制到另⼀块去,然后再把使⽤的空间⼀次清理掉。这样就使每次的内存回收都是对内存区间的⼀半进⾏回收。

优点:1.解决了标记-清除算法面对大量可回收对象时执行效率低的问题。2.避免了内存碎片。

缺点:将可用空间缩小为原来的一半,空间利用率较低。

新生代中大部分对象“朝生夕灭”,熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代内存空间。

将新生代划分为一个Eden区和两个Survivor区,大小为8:1:1

每次分配内存时只使用Eden区和其中一个Survivor区。发生垃圾回收时,将Eden和Survior中仍然中存活的对象一次性复制到另外一块Survivor空间中,然后直接清理掉Eden和已经使用过的Survivor区。

img

③标记-整理算法

根据⽼年代的特点推出的⼀种标记算法,标记过程仍然与“标记-清除”算法⼀样,但后续步骤不是直接对可回收对象回收,⽽是让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存。

(复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法)

img

优点:不会产生内存碎片。

缺点:移动存活对象并更新引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。(即Stop the world)

④分代收集算法

当前虚拟机的垃圾收集都采⽤分代收集算法,根据对象存活周期的不同将内存分为⼏块,⼀般将java堆分为新⽣代和⽼年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法

新⽣代中,每次收集都会有⼤量对象死去,所以可以选择标记-复制算法,只需要付出少量存活对象的复制成本就可以完成每次垃圾收集。

⽼年代 的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择 标记 - 清除 标记 - 整理 算法进⾏垃圾收集。

11.垃圾收集器

垃圾收集器主要有:Serial、Serial Old、ParNew、Parallel Scavenge、Parallel Old、CMS、G1

①Serial收集器

Serial收集器一个新生代、单线程的收集器,采用标记-复制算法。它在进行垃圾回收时,必须暂停所有用户线程,直到它收集结束。(stop the world)

②ParNew收集器

ParNew收集器一个新生代、多线程的收集器,采用标记-复制算法。只有Serial和ParNew收集器能与CMS配合工作。ParNew收集器是激活CMS后的默认新生代收集器。

③Parallel Scavenge收集器

Parallel Scavenge收集器是一款新生代收集器,基于标记-复制算法实现的。能够进行并行收集的多线程收集器。

④Serial Old收集器

Serial Old收集器:一个老年代、单线程的收集器,采用标记-整理算法。它在进行垃圾回收时,必须暂停所有用户线程,直到它收集结束。(stop the world)

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并发收集(多个GC线程),基于标记-整理算法实现。

⑥CMS收集器

CMS收集器是老年代收集器,基于标记-清除算法实现的。

⑦G1收集器

G1收集器:新生代+老年代(将Java堆划分成多个Region),面向全堆的收集器,不需要其他新生代收集器的配合工作。

搭配:

①新生代Serial+老年代Serial Old

②新生代ParNew+老年代Serial Old

③新生代Parallel Scavenge+老年代Parallel Old

④新生代ParNew+老年代CMS(Parallel Scavenge无法与CMS配合)

12. CMS收集器

CMSConcurrent Mark Sweep)收集器是⼀种以获取最短回收停顿时间为⽬标的收集器。它⽽⾮常符合在注重⽤户体验的应⽤上使⽤。

CMSConcurrent Mark Sweep)收集器是HotSpot虚拟机第⼀款真正意义上的并发收集器,它第⼀次实现了让垃圾收集线程与⽤户线程(基本上)同时⼯作。

从名字中的Mark Sweep这两个词可以看出,CMS收集器是⼀种 标记-清除算法实现的,它的运作过程相⽐于前⾯⼏种垃圾收集器来说更加复杂⼀些。整个过程分为四个步骤:**(初始标记和重新标记这两步仍然要stop the world) **

stop the world:除垃圾收集器线程之外的线程都被挂起。

初始标记: 暂停用户线程,并标记下GC Roots能直接关联到的对象,速度很快 ;

并发标记: 同时开启GC和⽤户线程,⽤⼀个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为⽤户线程可能会不断的更新引⽤域,所以GC线程⽆法保证可达性分析的实时性。所以这个算法⾥会跟踪记录这些发⽣引⽤更新的地⽅。

从GC Root开始对堆中的对象进行可达性分析,找出存活的对象。

(书本:并发标记阶段就是进行GC Roots Tracing的过程)

重新标记: 暂停用户线程,重新标记阶段就是为了修正并发标记期间因为⽤户程序继续运⾏⽽导致标记产⽣变动的那⼀部分对象的标记记录,这个阶段的停顿时间⼀般会⽐初始标记阶段的时间稍⻓,远远⽐并发标记阶段时间短。

并发清除: 同时开启⽤户线程和GC线程,清理掉在标记阶段判断的已经死亡的对象。

CMS收集器的优缺点:

从它的名字就可以看出它是⼀款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下⾯三个明显的缺点

CPU资源敏感

在并发阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。

⽆法处理浮动垃圾

CMS在并发清理阶段用户线程还在运行, 还会产生新的垃圾,这一部分垃圾产生在标记过程之后,CMS无法再当次过程中处理,所以只有等到下次gc时候在清理掉,这一部分垃圾就称作“浮动垃圾” 。

③它使⽤的回收算法–“标记清除算法会导致收集结束时会有⼤量空间碎⽚产⽣。

空间碎片太多的时候,将会给大对象的分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象的,只能提前触发 full gc。

为了解决这个问题,CMS提供了一个开关参数,用于在CMS顶不住要进行full gc的时候开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片没有了,但是停顿的时间变长了。

13. G1收集器

G1 (Garbage-First)是⼀款⾯向服务器的垃圾收集器,主要针对配备多颗处理器及⼤容量内存的机器。以极⾼概率满⾜GC停顿时间要求的同时**,**还具备⾼吞吐量性能特征。

被视为JDK1.7中HotSpot虚拟机的⼀个重要进化特征。

G1收集器采用“标记-复制”和“标记-整理”。从整体上看是基于“标记-整理”,从局部看,两个region之间是“标记-复制”。

它具备⼀下特点

并⾏与并发:G1能充分利⽤CPU、多核环境下的硬件优势,使⽤多个CPU(CPU或者CPU核⼼)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执⾏的GC动作,G1收集器仍然可以通过并发的⽅式让java程序继续执⾏。

分代收集:虽然G1可以不需要其他收集器配合就能独⽴管理整个GC堆,但是还是保留了分代的概念。

空间整合:与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。

可预测的停顿:这是G1相对于CMS的另⼀个⼤优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建⽴可预测的停顿时间模型,能让使⽤者明确指定在⼀个⻓度为M毫秒的时间⽚段内,消耗在垃圾收集上的时间不得超过N毫秒。

G1收集器的运作⼤致分为以下⼏个步骤:

①初始标记

暂停用户线程,初始标记只是标记下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。

②并发标记

同时开启用户线程和GC线程,并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活对象,这阶段耗时较长,但可与用户程序并发执行。

③最终标记

暂停用户线程,最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,这阶段需要停顿线程,但是可以并行执行。

④筛选回收

暂停用户线程,筛选阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。

[Regin:G1收集器将整个Java堆划分为多个大小相等的独立区域(Regin)]

G1收集器在后台维护了⼀个优先列表,每次根据允许的收集时间,优先选择回收价值最⼤的Region(这也就是它的名字Garbage-First的由来**)**。这种使⽤Region划分内存空间以及有优先级的区域回收⽅式,保证了GF收集器在有限时间内可以尽可能⾼的收集效率(把内存化整为零)。

dd

筛选回收:

对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉个旧的Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

G1收集器只有并发标记不会stop the world

14. 为什么CMS采用“标记-清除”算法而不采用“标记-整理”算法

因为CMS作为第一款实现用户线程和收集线程并发执行的收集器!当时的设计理念是减少停顿时间,最好是能并发执行!但是问题来了,如要用户线程也在执行,那么就不能轻易的改变堆中对象的内存地址!不然会导致用户线程无法定位引用对象,从而无法正常运行!而标记整理算法和标记复制算法都会移动存活的对象,这就与上面的策略不符!因此CMS采用的是标记清理算法

15. JDK1.8默认的垃圾回收器

默认:UseParallelGC

UseParallelGC 即 Parallel Scavenge + Parallel Old

UseParallelGC:并行收集器,同时运行在多个cpu之间,物理上的并行收集器,跟上面的收集器一样也是独占式的,但是它最大化的提高程序吞吐量,同时缩短程序停顿时间,另外它不能与CMS收集器配合工作。

16. 为什么新生代和老年代要采用不同的回收算法

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就许选择“标记-复制”算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记–清除”、“标记整理”算法来进行回收。

标记复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。

17. 垃圾回收怎么解决跨代引用的问题

记忆集(Remembered Set):一种用于记录从非收集区域指向收集区域指针集合的抽象数据结构,在对象层面来说就是非收集区域对象对收集区域对象的引用的记录。

记忆集存放在收集区域,比如在新生代里面存放着老年代对新生代对象的每一个引用。这样在收集新生代的时候,我们就可以根据记忆集知道哪些对象被老年代对象所引用,不能回收,这就解决了跨代引用的问题。

18. 类加载过程

类加载过程:加载、验证、准备、解析、初始化

其中验证、准备、解析3个部分统称为连接

1.加载

①通过一个类的全限定名来获取定义此类的二进制字节流。

②将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

③在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

在加载阶段,ClassLoader通过一个类的完全限定名查找此类字节码文件,并将这个字节码文件的静态数据结构转换成对应的运行时数据结构,并利用字节码文件创建一个class对象

2.验证

确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

3.准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

在准备阶段,进行分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

假设一个类变量的定义为:public static int value = 123;

变量value在准备阶段过后初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123是在程序被编译后,存放于类构造器()方法之中,所以把value赋值为123的操作要到类的初始化阶段才会被执行。

从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK7及之前,HostSpot使用永久代

来实现方法区时,实现是完全符合这种逻辑的。而在JDK8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

4.解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。

符号引用:以一组符号来描述所引用得目标,符号可以是任何形式得字面量,只要使用时能无歧义地定位到目标即可。

直接引用:直接引用是可以指向目标得指针、相对偏移量或者是一个能间接定位到目标得句柄。直接引用是和虚拟机实现得内存布局直接相关的。

5.初始化

初始化阶段就是执行类构造器()方法的过程。

19. 有哪些类加载器?

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承⾃ java.lang.ClassLoader :

①启动类加载器(Bootstrap Class Loader):这个类加载器负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。

②扩展类加载器(Extension Class Loader):这个类加载器负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。根据“扩展类加载器”这个名称,就可以推断处这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。

由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。

③应用程序类加载器(Application Class Loader):它负责加载用户类路径(ClassPath)上所有的类库。

由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,所以也称“系统类加载器”。

开发者同样可以直接在代码中使用这个类加载器。

如果应用程序中没有自定义自己的类加载器,一般情况下这个就是程序中默认的类加载器。

比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

20. 双亲委派模型

【注意】其实这个双亲翻译的容易让别⼈误解,我们⼀般理解的双亲都是⽗⺟,这⾥的双亲更多地表达的是“⽗⺟这⼀辈”的⼈⽽已,并不是说真的有⼀个 Mather ClassLoader 和⼀个 Father ClassLoader 。另外,类加载器之间的“⽗⼦”关系也不是通过继承来体现的,是由“优先级”来决定。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。当⽗类加载器为null时,会使⽤启动类加载器 BootstrapClassLoader 作为⽗类加载器。

img

21. 使用双亲委派模型的好处

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。

例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也就会变得一片混乱。

22. 如何打破双亲委派模型

打破双亲委派模型

方法1:自定义类加载器,重写ClassLoader类中的**loadClass()**方法;

方法2:使用线程上下文类加载器 Thread Context ClassLoader

如果不想打破双亲委派模型,就重写ClassLoader类中的**findClass()**方法即可。

23. JVM调优

JVM调优目标:使用较小的内存占用来获得较高的吞吐量或者较低的延迟。调优有几个比较重要的指标:

  • 内存占用:程序正常运行需要的内存大小。
  • 延迟:由于垃圾收集而引起的程序停顿时间。
  • 吞吐量:用户程序运行时间占用户程序和垃圾收集占用总时间的比值。

JVM调优工具:

(1)调优数据:

​ 可以依赖、参考的数据有系统运行日志、堆栈错误信息、GC日志、线程快照、堆转储快照等。

​ ①系统运行日志:系统运行日志就是在程序代码中打印出的日志,描述了代码级别的系统运行轨迹(执行的方法、入参、返回值等),一般系统出现问题,系统运行日志是首先要查看的日志。

​ ②堆栈错误信息:当系统出现异常后,可以根据堆栈信息初步定位问题所在,比如根据“java.lang.OutOfMemoryError: Java heap space”可以判断是堆内存溢出;根据“java.lang.StackOverflowError”可以判断是栈溢出;根据“java.lang.OutOfMemoryError: PermGen space”可以判断是方法区溢出等。

​ ③GC日志:程序启动时用 -XX:+PrintGCDetails 和 -Xloggc:/data/jvm/gc.log 可以在程序运行时把gc的详细过程记录下来,或者直接配置“-verbose:gc”参数把gc日志打印到控制台,通过记录的gc日志可以分析每块内存区域gc的频率、时间等,从而发现问题,进行有针对性的优化。

​ ④**线程快照**:顾名思义,根据线程快照可以看到线程在某一时刻的状态,当系统中可能存在请求超时、死循环、死锁等情况是,可以根据线程快照来进一步确定问题。通过执行虚拟机自带的“jstack pid”命令,可以dump出当前进程中线程的快照信息,

​ ⑤堆转储快照:程序启动时可以使用 “-XX:+HeapDumpOnOutOfMemory” 和 “-XX:HeapDumpPath=/data/jvm/dumpfile.hprof”,当程序发生内存溢出时,把当时的内存快照以文件形式进行转储(也可以直接用jmap命令转储程序运行时任意时刻的内存快照),事后对当时的内存使用情况进行分析。

(2)JVM调优工具:

​ ①用jps(JVM process Status)可以查看虚拟机启动的所有进程、执行主类的全名、JVM启动参数,

​ ②用jstat(JVM Statistics Monitoring Tool)监视虚拟机信息 jstat -gc pid 500 10 :每500毫秒打印一次Java堆状况(各个区的容量、使用容量、gc时间等信息),打印10次。

​ ③用jmap(Memory Map for Java)查看堆内存信息,执行jmap -histo pid可以打印出当前堆中所有每个类的实例数量和内存占用。

​ ④利用jconsole、jvisualvm分析内存信息(各个区如Eden、Survivor、Old等内存变化情况).

​ ⑤分析堆转储快照,前面说到配置了 “-XX:+HeapDumpOnOutOfMemory” 参数可以在程序发生内存溢出时dump出当前的内存快照,也可以用jmap命令随时dump出当时内存状态的快照信息,dump的内存快照一般是以.hprof为后缀的二进制格式文件。

24. OOM异常

java.lang.OutOfMemoryError: Java heap space ------>java堆内存溢出,此种情况最常见,一般由于内存泄露或者堆的大小设置不当引起。对于内存泄露,需要通过内存监控软件查找程序中的泄露代码,而堆大小可以通过虚拟机参数-Xms,-Xmx等修改。
java.lang.OutOfMemoryError: PermGen space ------>java永久代溢出,即方法区溢出了,一般出现于大量Class或者jsp页面,或者采用cglib等反射机制的情况,因为上述情况会产生大量的Class信息存储于方法区。此种情况可以通过更改方法区的大小来解决,使用类似-XX:PermSize=64m -XX:MaxPermSize=256m的形式修改。另外,过多的常量尤其是字符串也会导致方法区溢出。
java.lang.StackOverflowError ------> 不会抛OOM error,但也是比较常见的Java内存溢出。JAVA虚拟机栈溢出,一般是由于程序中存在死循环或者深度递归调用造成的,栈大小设置太小也会出现此种溢出。可以通过虚拟机参数-Xss来设置栈的大小。

25. 常用的JVM调优参数

参数说明实例
-Xms初始堆大小,默认物理内存的1/64-Xms512M
-Xmx最大堆大小,默认物理内存的1/4-Xms2G
-Xmn新生代内存大小,官方推荐为整个堆的3/8-Xmn512M
-Xss线程堆栈大小,jdk1.5及之后默认1M,之前默认256k-Xss512k
-XX:NewRatio=n设置新生代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4-XX:NewRatio=3
-XX:SurvivorRatio=n年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:8,表示Eden:Survivor=8:1:1,一个Survivor区占整个年轻代的1/8-XX:SurvivorRatio=8
-XX:PermSize=n永久代初始值,默认为物理内存的1/64-XX:PermSize=128M
-XX:MaxPermSize=n永久代最大值,默认为物理内存的1/4-XX:MaxPermSize=256M
-verbose:class在控制台打印类加载信息
-verbose:gc在控制台打印垃圾回收日志
-XX:+PrintGC打印GC日志,内容简单
-XX:+PrintGCDetails打印GC日志,内容详细
-XX:+PrintGCDateStamps在GC日志中添加时间戳
-Xloggc:filename指定gc日志路径-Xloggc:/data/jvm/gc.log
-XX:+UseSerialGC年轻代设置串行收集器Serial
-XX:+UseParallelGC年轻代设置并行收集器Parallel Scavenge
-XX:ParallelGCThreads=n设置Parallel Scavenge收集时使用的CPU数。并行收集线程数。-XX:ParallelGCThreads=4
-XX:MaxGCPauseMillis=n设置Parallel Scavenge回收的最大时间(毫秒)-XX:MaxGCPauseMillis=100
-XX:GCTimeRatio=n设置Parallel Scavenge垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)-XX:GCTimeRatio=19
-XX:+UseParallelOldGC设置老年代为并行收集器ParallelOld收集器
-XX:+UseConcMarkSweepGC设置老年代并发收集器CMS
-XX:+CMSIncrementalMode设置CMS收集器为增量模式,适用于单CPU情况。

26.打印gc日志

打开的Run/Debug Configurations中点击左上角的加号,然后选"Application",然后在左边详细信息中,将Main class设置为想要查看GC日志的类(需要完整的路径,包括其所在的包,如图中的learning_java.book.ReferenceCountingGC),然后在下面的VM options中输入-XX:+PrintGCDetails,最后点击OK保存设置即可。

在页面输入System.gc();打印日志;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值