一、JVM篇,认识运行时数据区、垃圾回收算法、以及垃圾回收器

1. 运行时数据区

    java虚拟机在执行java程序时会把它锁管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间。

下图是java的运行时数据区

程序计数器:它是一块较小的内存空间,用于记录当前线程执行的行号。字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能。

    这个区域是线程私有的,因为每个CPU内核在一个时刻只能执行一个线程,为了线程切换能够恢复到正确的执行位置,每个线程都需要一个独立的程序计数器。

    当执行本地方法时,这个计数器的为空(undefined),此内存区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈

虚拟机栈也是线程私有的,生命周期与线程相同,虚拟机栈描述的是java方法执行的内存模型:每个方法在执行的同事都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到执行完成的过程,就是一个栈帧在虚拟机栈中从入栈到出栈的过程。

如果虚拟机栈的栈深度大于虚拟机所允许的深度(当嵌套调用方法过多而无法分配新的空间时),则抛出StackOverflowError异常,如果当虚拟机栈扩展时无法申请到足够的内存,则抛出OutOfMemoryError异常(创建大量的线程同时执行方法的情况),,当栈帧内存分配不足时会抛出StackOverflowError,当栈帧数量过多无法分配新的栈帧时抛出OutOfMemoryError异常。

本地方法栈

本地方法栈几乎和虚拟栈一样,只不过本地方法栈执行的本地方法(Native Method),有些虚拟机实现的时候将其和虚拟机栈合二为一。

Java堆

对大多数应用来说,Java堆是java虚拟机管理的内存中最大的一块。Java堆是被所有线程共享的一块区域,在虚拟机创建时创建。

此区域的唯一目的就是存放java对象实例。几乎所有对象的实例都在这里分配内存。

    Java堆也是垃圾收集器管理的主要区域,由于现代的垃圾收集器基本采用分区的算法,所以java堆还可以细分为:新生代和老年代。从内存分配的角度来看,线程共享的java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB),java堆的分区是为了更快的分配内存和回收内存。

    java堆可以通过-Xms和-Xmx控制,-Xms表示虚拟机初始化时的java堆大小,而-Xmx表示java堆可分配的最大内存大小。当堆中的内存使用完,再进行实例内存分配时,会抛出OutOfMemoryError的异常。

方法区

    方法区也是被线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然java虚拟机把方法区描述为java堆的一部分,但是它却有一个别名(Non-Heap,非堆)

    方法区经常被称为“永久代”(Permanent Generation),但是本质上两者并不等价,仅仅是因为Hotspot虚拟机的设计团队把GC分类收集扩展至方法区,或者说用了永久代的方法而已。

    (永久代,从字面意义上理解,存放的内容是不需要回收的,垃圾回收在这个区域还是比较少出现的,回收的主要目的是针对常量池的回收和对类型的卸载),但是这个区域的垃圾回收是有必要的。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

    永久代可以使用-XX:MaxPermSize来限制上限。

运行时常量池

    运行时常量池是方法区的一部分,Class文件中除了有类的版本。字段。方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号的引用,这部分内容将会在类加载以后进入方法区的运行时常量池存放。

    当常量池无法申请到内存时会排出OutOfMemoryError异常

直接内存

    直接内存并不是虚拟机运行时数据区的一部分,在jdk1.4引入的NIO中,引入了一种基于通道(channel)与缓冲区(Buffer)的I/O的方式,,他可以直接使用Native函数库分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。

    显然,直接内存的分配不会受到java堆大小的限制,但是会受到本机总内存(操作系统级别、或者磁盘分区)等的限制,当直接内存动态扩展不足时会抛出OutOfMemoryError异常。

 

OutOfMemoryError异常测试

java堆异常

java堆抛出OutOfMemoryError,即java堆中创建了大量的对象而不能被垃圾回收器回收

代码:

 

package com.zhanjp.oom;

import java.util.ArrayList;
import java.util.List;

/**
 * Describe 测试java堆溢出
 * 设置参数 -Xms10M -Xmx10M -XX:+printGCDetails
 * Created by zhanjp on 2018/4/29
 */
public class HeapOOMTest {

    static class OOMObject{

    }

    public static void main(String[] args){
        List<OOMObject> list = new ArrayList<>();
        while (true){
            list.add(new OOMObject());
        }
    }
}

 

设置好jvm运行参数:-Xms10M -Xmx10M -XX:+PrintGCDetails

运行后报错:

由此可见,老年代几乎被占满而无法创建新的对象,抛出了OutOfMemoryError异常。

也可以使用 -XX:+HeapDumpOnOutOfMemoryError参数,在出现OutOfMemoryError异常时

获得的.hprof文件,需要用Eclipse的 Memory Analyzer分析

 

虚拟机栈异常:StackOverflowError

测试类:

 

package com.zhanjp.oom;

/**
 * Describe 虚拟机栈StackOverflowError异常
 * 虚拟机参数 -Xss128k
 * Created by zhanjp on 2018/4/29
 */
public class VmstackSOFTest {

    public int stackDeepth = 1;

    private void stackLeak(){
        stackDeepth ++;
        stackLeak();
    }

    public static void main(String[] args){
        VmstackSOFTest test = new VmstackSOFTest();
        test.stackLeak();
    }
}

 

 测试结果:

 

虚拟机栈异常:OutOfMemoryError

    在出现StackOverflowError异常时有错误堆栈可以阅读,相对比较容易找到错误所在,但是,如果多线程导致内存溢出,再不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

测试类:

    

package com.zhanjp.oom;

/**
 * Describe java虚拟机栈OutOfMemoryError异常
 * 虚拟机参数:-Xss2M
 * Created by zhanjp on 2018/4/29
 */
public class VmstackOOMTest {

    private void dontStop(){
        while (true){

        }
    }

    public void stackLeakByThread(){
        while (true){
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }

    public static void main(String[] args){
        VmstackOOMTest test = new VmstackOOMTest();
        test.stackLeakByThread();
    }
}

 

根据深入理解JAVA虚拟机一书,这里应该会抛出下面的错误,但是在我的电脑上一跑这段代码就会导致死机,尝试未果,这里只贴出书上的结果:

 

 

方法区和运行时常量池溢出

    由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起,jdk1.7开始逐步“去除永久代”的事情

    String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含了一个等于此String对象的字符串,则返回代表池中这个字符串的String对象,否则,将此String对象添加至常量池中,并返回此对象的引用。

在JDK1.6中使用String.intern()会在常量池中放置String对象,通过-XX:PermSize和-XX:MaxPermSize限制方法区的大小,可以使程序抛出OutOfMemoryError: PermGen space异常,但是在jdk1.7中,这个限制已经不起任何作用了

    测试代码如下:

 

package com.zhanjp.oom;

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;

/**
 * Describe 方法区溢出测试,jdk1.6可用,jdk1.7不可用
 * VM参数:-XX:PermSize10M -XX:MaxPermSize10M
 * Created by zhanjp on 2018/4/29
 */
public class MethodAreaOOMTest {

    static class OOMObject{

    }

    public static void main(String[] args)throws Exception{

        //jdk1.6中的测试方法
        /*List<String> list = new ArrayList<>();
        int i = 0;
        while (true){
            list.add(String.valueOf(i++).intern());
        }*/

        //jdk1.7中的方法
        while (true){
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o, objects);
                }
            });
            enhancer.create();
        }
    }
}

 

1.7下的测试结果:

方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。所以对于这个区域的测试,可以通过运行时生成大量的类去填满方法区,直到溢出。下面借助CGLib直接操作字节码在运行时生成大量的动态类(这种类型的应用在现实的主流框架中有很多:Spring、Hibernate、动态语言Groovy等)。所以这也可能在实际应用中碰到。

本机直接内存溢出

    DirectMemory容量可以通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与java堆最大值(-Xmx)一样,以下代码中越过了DirectByteBuffer类,直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有rt.jar中的类才能使用Unsafe的功能)。因为,虽然使用DirectByteBuffer分配内存也会抛出内存溢出的异常,但是它并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,真正申请分配内存的方法是unsafe.allocateMemory()。

 

代码如下:

package com.zhanjp.oom;

import sun.misc.Unsafe;

import java.lang.reflect.Field;

/**
 * Describe 直接内存OutOfMemoryError异常
 * VM参数:-Xmx20M -XX:MaxDirectMemorySize=10M
 * Created by zhanjp on 2018/4/29
 */
public class DirectMemoryOOMTest {

    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws IllegalAccessException {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true){
            unsafe.allocateMemory(_1MB);
        }
    }
}

 

结果:

由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果发现OOM之后Dump文件很小,而程序中又直接或间接的使用了NIO,那就可以考虑一下这方面的原因。

 

 

java垃圾回收器与内存回收策略

什么是垃圾回收?怎么判断一个对象是否需要回收?

jvm垃圾回收,即回收不可用的对象,下面是一些垃圾回收的相关算法:

引用计数算法:

    引用计数算法,就是记录每个对象的引用数量,当对象无任何引用时,则认为该对象可以回收。

    这个算法有个明显的缺点,就是可能有两个对象都没有用了,但是它们之间互相引用,垃圾回收算法则不能回收这一部分对象。

可达性分析算法:

    这个算法的基本思想是通过一系列成为“GC Roots”的对象作为起点,从这些节点向下搜索,搜索走过的路劲称为引用链,当一个对象到GC Roots没有任何引用链相连时,则此对象是不可用的。

如下图总object5/6/7没有到GC Roots的引用链,则是可回收的

 

在java语言中,可作为GC Roots的对象包括以下几种:

    1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。

    2. 方法区中类静态属性引用的对象

    3. 方法区中常量引用的对象

    4. 本地方法中JNI(即一般说的Native方法)引用的对象

 

以上算法中,不管是引用计数法还是可达性分析算法都要判断对象的引用是否可用,按照java定义的说法,一个对象只存在两种状态,引用和未引用,但是现实中也有一些对象,当内存充足时,我们希望它可以留在内存中,而内存不足时去释放这一部分对象。

 

这种思想,在jdk1.2之后通过引用的分级来实现,将引用分为强引用、软引用、弱引用、虚引用四种。

强引用:类似于“Object obj = new Object()”这种,只要强引用还在,这个对象永远不会被回收

软引用:jdk1.2之后,提供了SoftReference类来实现软引用。在系统发生内存溢出异常之前,这些对象将会被回收掉,如果内存还是不能满足分配,则会抛出异常

弱引用:jdk1.2之后,提供了WeakReference类来实现,这种引用遇到垃圾回收就会被回收掉

虚引用:jdk1.2之后,提供了PhantomReference类来实现,这种引用无法获取到对象实例,设置虚引用的唯一目的就是在这个对象被垃圾回收时受到一个系统通知

 

垃圾回收算法

1. 标记-清除算法:

    Mark-Sweep算法,算法分为“标记”和“清除”两个阶段,这种算法有两种缺点:一个是效率问题,标记和清除两个过程的效率都不太高,另一个是标记清除算法之后会产生大量的内存碎片,内存碎片过多会导致,在后面分配较大的对象时,会因为找不到合适的连续内存而不得不提前触发一次垃圾回收。

 

复制算法

    为了解决效率问题,复制算法被提出来,这种算法把内存分为两块,当一块内存即将用完的时候,触发复制算法,将可用的对象复制到另一块内存上,然后一次性清除之前的内存。这种算法效率较高,但是内存缩小为以前的一半

 

标记-整理算法

由于复制算法较耗内存,如果不想耗费一半的内存用来复制操作,就需要有额外的空间进行担保,新生代之所以采用复制算法,因为还有老年代对其进行担保,但是老年代就不可以了。所以提出了“标记-整理”这种算法:标记完可用对象之后,将可用对象向一端移动,然后直接清理掉端边界以外的内存。

 

垃圾回收器

1. Serial收集器

    Serial是最基本也是最早的收集器,在JDK1.31之前是虚拟机新生代收集的唯一选择,这个收集器是一个单线程的收集器,这里的单线程不仅仅说明它只会使用一个CPU或者一个线程去完成垃圾收集工作,更重要的是它在收集过程中,必须停止其它所有工作线程。下图示意了Serial/Serial Old收集器的运行过程

虽然到现在它已经很老了,它在桌面应用(收集几十兆甚至一两百兆的新生代)的使用场景里还是一个很好的选择

2. ParNew收集器

    ParNew是Serial收集器的多线程版本,它的工作过程如下图所示

ParNew收集器是在Server模式下虚拟机中首选的新生代收集器,因为目前只有它能够和CMS收集器配合工作。在使用了-XX:+UseConcMarkSweepGC选项后,老年代使用CMS收集器,这时新生代默认选用ParNew收集器,或者使用-XX:UseParNewGC选项强制指定ParNew

 

3. Parallel Scavenge收集器

从名字来看,它也是一个并行的收集器,那它和ParNew有什么区别呢?

Parallel Scavenge收集器与其它收集器的关注点不同,CMS等收集器关注点是尽可能的缩短垃圾收集时用户线程的停顿时间,而这个收集器目的是达到一个可控制的吞吐量(Throughput)。

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

Parallel Scavenge提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMills 和直接设置吞吐量大小的-XX:GCTimeRatio参数。

最大垃圾收集停顿时间是用新生代的空间大小换取的,因为更小的新生代大小,意味着垃圾回收更快。

Parallel Scavenge收集器还有一个参数 -XX:+UseAdaptiveSizePolicy,这个参数打开之后,就不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大吞吐量。这种调节方式成为GC自适应的调节策略(GC Ergonomics)。如果采用这种方式,用户只需要把基本的内存数据设置好(-Xmx设置最大堆)、(MaxGCPauseMillis参数设置最大停顿时间)、或者(GCTimeRatio吞吐量)给虚拟机设立一个优化目标。自适应调节也是Parallel Scanvenge收集器于ParNew收集器的一个重要区别。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,采用标记-整理的算法。这个收集器主要是给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:一种是在JDK1.5之前和Parallel Scanvenge收集器搭配使用,另一种是CMS的后备预案,当CMS发生Concurrent Mode Failure时使用。Serial Old收集器的工作过程如下:

Parallel Old收集器

Parallel Old是Parallel Scanvenge收集器的老年代版本,使用多线程和“标记-整理”算法实现,这个收集器是jdk1.6之后才出现的,在此之前,新生代的Parallel Scanvenge一直处于比较尴尬的状态,因为Parallel Scanvenge收集器,只能和Serial Old收集器搭配使用,由于Serial Old是单线程的,使用了Parallel Scanvenge收集器也未必能在整体应用上获得吞吐量最大化的效果,在硬件比较高级的情况下,这种组合甚至没有ParNew+CMS组合更给力

    而Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的组合,在注重吞吐量以及CPU资源敏感的场合,都可以考虑Parallel Scanvenge加Parallel Old的组合。

    其工作流程如下:

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的java应用几种在互联网或者B/S系统的服务器上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短。CMS收集器就非常符合这类应用的需求。

CMS基于“标记-清除”算法实现,它的运作过程较为复杂,分为4个步骤:

    1. 初始标记

    2. 并发标记

    3. 重复标记

    4. 并发清除

其中,初始标记、重新标记这两个阶段任然需要”Stop The World“,初始标记只是标记一下GC Roots能够直接关联的对象,速度很快,并发标记就是进行GC Roots的Tracing的过程,而重新标记阶段是为了修正并发标记阶段音用户程序继续运作而导致标记的变动,这个阶段的停顿时间一般比初始标记稍长一点,但是远比并发标记时间短。

    由于整个过程中耗时最长的阶段并发标记、和并发清除过程是和用户线程一起执行的,总体上来说,CMS收集器的内存回收过程是与用户线程一起兵法执行的。通过下图可以比较清楚的看到CMS收集器运作步骤中并发和需要停顿的时间。

CMS收集器的优缺点:

    优点:并发收集、低停顿、

    缺点:1. 对CPU资源比较敏感,虽然可以和用户线程并发进行,但是会占用一部分线程导致用户程序变慢。总吞吐量变低。CMS默认启动的线程数是(CPU数量+3)/4,也就是说CPU数量越多,这个值越接近CPU数量的1/4,而当CPU较少时,CMS对用户程序的影响就可能变得很大。为了应付这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器,在并发标记、清理的时候让GC线程和用户线程交替执行,以减少对CPU独占的时间,但是这个收集器在实验过程中效率较低,i-CMS已被声明为“Deprecated”,即不再提倡使用。

2. CMS无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC产生,由于CMS再清理的同事,用户线程还在产生新的垃圾,那么这些新产生的垃圾就被称为浮动垃圾,留到下一次GC时处理。因此CMS收集器不能等到老年代几乎被填满时才开始GC,需要给用户线程预留一部分空间。预留空间可以用-XX:CMSInitiatingOccupancyFraction配置启动CMS GC的阈值,在JDK1.5中,CMS收集器在老年代空间使用了68%的时候就会被启动,这是一个比较保守的值,在jdk1.6中,这个值已经被提甚至92%,如果在CMS GC过程中内存无法满足程序需要,就会触发“Concurrent Mode Failure”失败,此时会用Serial Old收集器重新收集老年代的垃圾,这个停顿时间就很长了,所以参数“-XX:CMSIntiatingOccupancyFraction”设置的太高很容易导致大量的“Concurrent Mode Failure”失败,性能反而降低。

3. 还有一个缺点,由于CMS是基于“标记-清除”算法实现的,这样会产生很多的内存碎片(不连续的内存空间),这样可能会有这样一种情况:老年代还有很大的空间,但是无法找到足够大的连续空间分配给对象,不得不提前触发一次Full GC。

为了解决这个问题,虚拟机设计者提供了两个参数:

-XX:+UseCMSCompactAtFullCollection,用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,这个配置默认是打开的,这样空间碎片的问题没有了,但是停顿时间不得不变长。

另一个配置:-XX:CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的FullGC后,跟着来一次带压缩的(默认值为0,表示每次进入FullGC都要进行碎片整理)

G1收集器

G1收集器是当前收集器技术发展的最前沿成果之一,其特点是:

1. 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“stop the world”的时间,部分其他收集器原本需要停顿java线程执行的GC动作,G1收集器仍然可以通过并发方式让java程序继续执行

2. 分代收集:G1收集器不需要和其它收集器配合就可以管理整个java堆,他能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。

3. 空间整合: G1收集器整体上看是基于“标记-整理”算法实现的

4. 可预测的停顿:这是G1相对于CMS的另一大优势,G1可以建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾回收上的时间不得超过N毫秒,这几乎已经是实时(RTSJ)的垃圾回收器的特征了

    G1收集器与之前收集器不同的是,它将整个java堆划分成为多个大小想等的独立区域(Region),虽然还保留有新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了,都是一部分Region(不需要连续)的集合

    G1收集器质素一能建立可预测的停顿时间模型,是因为它可以有计划的避免在整个java堆中进行全区域的垃圾收集。G1跟踪哥哥Region里面的垃圾堆积的价值大小(回收获得的空间大小和所需时间的经验值),在后台维护了一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1在有限时间内可以获取尽可能高的收集效率。

如果你现在采用的收集器没有出现问题,那就没有任何理由现在去选择G1,如果你的应用追求低停顿,那G1现在已经可以作为一个可尝试的选择,如果你的应用追求吞吐量,那G1并不会为你带来特别的好处。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值