自动内存管理机制

概述:
  • 从事C/C++的程序开发人员来说,在内存管理领域拥有每个对象的所有权,又担负着每个对象生命开始到终结的维护责任。
  • Java虚拟机自动内存管理机制,不再为每个new操作写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题。

Java内存区域与内存溢出异常

Java虚拟机在执行Java程序的过程中会把它所管理的内存分为若干个不同的数据区域,每个区域有各自的创建和销毁时间以及用途。
Java虚拟机所管理的内存将会包括以下几个运行时数据区域:

  • 程序计数器:当前线程所执行的字节码的行号指示器,字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址,如果正在执行的是Native方法,这个计数器值则为空,此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。(每个线程独立的计数器)
  • Java虚拟机栈:每个线程也是私有的栈。描述的是Java方法执行的内存模型,每个方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。Java虚拟机规范中如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,但扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
  • 本地方法栈:类似与虚拟机栈,区别在于虚拟机栈为虚拟机执行Java方法服务,而本地方法栈为虚拟机使用到的Native方法服务。有地虚拟机(HotSpot)直接把本地方法栈和虚拟机栈合二为一了。本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
  • Java堆:所有线程共享的一块最大区域。在虚拟机启动时创建,存放对象实例。细分为:新生代和老年代。继续细分:Eden、From Survivor、To Survivor等。虚拟机规范规定:Java堆可以处于物理上不连续的内存空间,只哟逻辑上连续即可。(-Xmx和-Xms控制)扩展实现的。如果堆中没有内存完成实例分配,并且堆也无法在扩展时,将会抛出OutOfMemoryError异常。
  • 方法区:也是各个线程共享的内存区域。存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。JDK1.7的HotSpot中已经把放在永久代的字符串常量池移出。不实现垃圾收集,这区域的数据在常量池的 回收和对类的卸载也是要回收数据的。当方法区无法满足内存分配需求时将抛出OutOfMemoryError异常。
  • 运行时常量池:方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行常量池中存放。

直接内存:不是虚拟机运行时数据的一部分,也不是Java虚拟机规范中定义的内存区域。
JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/OFANGSHI ,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。避免了Java堆和Native堆中来回复制数据,提高了性能。
受到本机内存大小以及处理器寻址空间的限制,所以配置虚拟机参数除了关注虚拟机内存也同时关注物理内存下的直接内存,避免导致的OutOfMemoryError异常。

对象创建:

  1. 虚拟机遇到一条new指令,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。
  2. 类加载检查通过后,接下来虚拟机将为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定,等同于把一块确定大小的内存从Java堆中划分出来。根据Java堆中的内存是否规整,采用指针碰撞(规整)和空闲列表(不规整)的分配方式。
  3. Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定,在使用Serial/ParNew等带有Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时通常采用空闲列表。
  • 考虑对象创建是否是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针分配内存。解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理(实际上虚拟机采用CAS配上失败重试的方式保证更新操作的院子性),另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存称为本地线程分配缓冲。哪个线程要分配内存就在那个线程的本地线程分配缓冲上分配,只有用完并分配新的缓冲时,才需要同步锁定。虚拟机是否使用本地线程分配缓冲可以通过:-XX:+/-UserTlab参数来设定。
  • 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)、如果使用本地分配缓冲分配时进行,保证了对象的实例字段在Java代码中可以不赋初始值直接使用。
  • 接下来虚拟机要对对象进行必要的设置。

对象的内存布局:

HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
对象头:自身运行时的数据(哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳);另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

实例数据:对象真正存储的有效信息,程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是子类中定义的,都需要记录起来。存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。相同宽度的字段总是被分配到一起。
对齐填充:占位符作用,HotSpot的自动内存管理系统要求对象起始地址必须是8字节的整数倍。

对象的访问定位:

句柄方式:Java堆会划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址。

直接指针:Java堆对象的布局中就必须考虑如何防止访问类型数据相关的信息,而reference中存储的直接就是对象地址。

两者比较:
  • 句柄reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时,只会改变句柄中的实例数据指针,而reference本身不需要修改。
  • 直接指针就是速度快,节省了一次指针定位的时间开销。

OutOfMemoryError异常:

简称OOM,虚拟机内存的几个运行区域除了程序计数器外,都有发生过的。
各个运行区域存储的内容,根据异常信息快速判断是哪个区域的内存溢出,以及出现异常如何处理。
可以使用控制台命令或者开发Eclipse的Debug工具页签中设置参数

Java堆溢出:只要不断创建对象,并且保证GC到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象到达最大堆的容量限制后就会产生内存溢出异常。
/**
    限制Java堆的大小为20M,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展)
*/
public class HeapOOM{
    static class OOMObject{
        //定义一个静态内部类用来创建对象
    }
    
    public static void main(String[] args){
        List<OOMObject> list = new ArrayList<>();
        while(true){
            list.add(new OOMObject());//不断创建对象
        }
    }
}
运行结果:
java.lang.OutOfMemoryError:Java heap space...

解决方案:

内存映像分析工具(Eclipse Memory Analyzer)对Dump出来的堆转存快照进行分析,重点是确认内存中的对象是否是必要的,也就是到底是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
  • 内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的路径与GC Root相关联并导致垃圾收集器无法自动回收它们的。根据泄漏对象的类型信息及GC Roots引用链的信息,可以比较准确地定位出泄漏代码的位置。
  • 如果不存在泄漏,内存中的对象还必须存活,那就应当检查虚拟机的堆参数(-Xmx与-Xms)与机器物理内存对比是否还可以调大,代码上是否某些对象生命周期过长。

虚拟机栈和本地方法栈溢出:HotSpot中虽然-Xoss参数(设置本地方法栈大小)存在,但实际无效的,因为该虚拟机不区分虚拟机栈和本地方法栈之分。栈容量由-Xss参数设定。
两种异常
  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,将抛出OutOfMemoryError异常。
/**
    -Xss128K
    使用-Xss参数减少栈内存容量。结果抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小
    
    
*/
public class JavaVMStackSOF{
    private int stackLength = 1;
    
    public void stackLeak(){
        stackLength++;
        stackLeak();
    }
    
    public static void main(String[] args){
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try{
            oom.stackLeak();
        }catch(Throwable e){
            System.out.println(oom.stackLeanth);
            throw e;
        }
    }
}
运行结果:2402
java.lang.StackOverflowError异常
实验表明:在单个线程下,无论栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常
实验结果表明;单线程结果抛出的始终是栈溢出,如果不限于单线程通过不断地建立线程的方式可以产生内存溢出。
虚拟机提供了参数来控制 Java堆和方法区这两部分内存的最大值,剩余的减去最大堆容量和最大方法区容量剩下的内存就由虚拟机栈和本地方法栈瓜分了,每个线程分配的栈容量越大,可以建立的线程数量自然减少,建立线程时就越容易把剩下的内存耗尽。
在不能减少线程数和更换64为虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

方法区和运行时常量池溢出:String.intern()是一个Native方法,作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串String对象,否则将此String对象包含的字符串添加到常量池中,并且返回String对象的引用。
-XX:PermSize  -XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量。
/*
    -XX:PermSize=10M  -XX:MaxPermSize=10M
*/
public class RuntimeConstantPoolOOM{
    public static void main(String[] args){
        List<String> list = new ArrayList<String>();
        int i = 0;
        while(true){
            list.add(String.valueOf(i++).intern());
        }
    }
}
运行结果:java.lang.OutOfMemoryError
运行时常量池溢出,jdk1.6的结果不同于1.7的结果,1.7将会永远运行下去,不会得到结果。

String.intern()返回引用的测试,如下代码在1.7和1.6中是不同的结果:
public class RuntimeConstantPoolOOM{
    public static void main(String[] args){
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);
        
        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }
}
jdk1.6  false  false   intern()方法会把首次遇到的字符串实例复制到永久代中,而StringBuilder创建的实例在堆上,所以属于不同的引用。
jdk1.7  true   false    intern()方法不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和StringBuilder创建的是同一个实例。
对于str2返回的false,这个字符串在执行StringBuilser.toString()之前已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回true

本机直接内存溢出:通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值一样
/**
    -Xmx20M  -XX:MaxDirectMemorySize=10
*/
public class DirectMemoryOOM{
    private static final int_1MB = 1024*1024;
    public static void main(String[] args)throws Exception{
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccesssible(true);
        Unsafe unsafe = (Unsafe)undafeField.get(null);
        while(true){
            unsafe.allocateMemory(_1MB);
        }
    }
}
运行结果:java.lang.OutOfMemoryError
由于DerectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果读者发现OOM之后的Dump文件很小,而程序中又直接或间接使用了NIO。那就可以考虑是不是这方面的原因。

垃圾收集器与内存分配策略
垃圾收集(GC)在排查各种内存溢出和内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们需要对这些自动化的技术实施必要的监控和调节。

Java运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生(而灭);栈中的栈帧随着方法的进入和退出有条不紊地执行这出栈和入栈操作。

每个栈帧分配多少内存基本是在类结构确定下来时就已知的,因此每个区域的内存分配和回收都具备确定性。因此这几个区域不需要过多考虑回收问题,因为方法结束或者线程结束时内存自然就随着回收了。

Java堆和方法区就不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也不可能一样,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的这部分内存。

判断对象的存亡:

引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1,引用失效就减1;
但一个缺点就是很难解决对象之间相互循环引用的问题。
public class ReferenceCountingGC{
    public Object instance = null;
    private static final int _1MB = 1024*1024;
    
    private byte[] bigSize = new byte[2*_1MB];//这个成员属性的唯一意义就是占点内存,以便能在GC中看清楚回收日志
    
    public static void testGC(){
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        
        objA = null;
        objB = null;
        
        //发生GC, objA和objB是否被回收
        System.gc();
    }
}
运行结果的内存由大变小,说明虚拟机并没有因为这两个对象相互引用就不回收。

可达性分析算法:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
如下图对象5、6、7虽然互相有关联,但它们的GC Roots是不可达的,所以将被回收。

GC Roots的对象有以下几种:
  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法中JNI(Native方法)引用的对象
JDK1.2之前分为被引用和没有被引用两种状态,JDK1.2之后进行了扩充,强引用、软引用、弱引用、虚引用4种。
  • 强引用Object obj = new Object(),只要强引用存在垃圾收集器永远不会回收被引用的对象
  • 软引用描述一些还有用但并非必需的对象。JDK1.2之后提供了SoftReference,系统将要发生内存溢出之前将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用非必需对象,被弱引用的关联对象只能生存到下一次垃圾收集发生之前,JDK1.2之后提供了WeakReference类来实现弱引用。
  • 虚引用一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时受到一个系统通知。JDK1.2提供了PhantomReference类来实现虚引用。
不可达的对象真正被回收的两次标记:
  1. 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,将会被第一次标记并且以此筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况视为“没有必要执行”。
  2. 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它,即虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是如果一个对象在finalize()方法中执行缓慢或者发生死循环将可能会导致队列其他对象永久等待。finalize()方法是对象逃脱命运的最后一次机会,稍后GC将对队列的对象进行第二次标记,如果对象在finalize方法中与引用链关联上,将被移除即将回放的集合。
  3. /**
        对象可以在被GC时自我拯救
        机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
    */
    public class FinalizeEscapeGC{
        public static FinalizeEscapeGC SAVE_HOOK = null;
        public void isAlive(){
            System.out.println("still alive");
        }
        
        @Override
        protected void finalize()throws Throwable{
            super.finalize();
            System.out.println("executed");
            FinalizeEscapeGC.SAVE_HOOK = this;
        }
        
        public static void main(String[] args)throws Throwable{
            SAVE_HOOK = NEW FinalizeEscapeGC();
            
            //对象第一次成功拯救自己
            SAVE_HOOK = null;
            System.gc();
            //因为finalize方法优先级很低,所以暂停0.5秒等待
            Thread.sleep(500);
            if(SAVE_HOOK != null){
                SAVE_HOOK.isAlive();
            }else{
                System.out.println("dead");
            }
            
            //对象自救失败
            SAVE_HOOK = null;
            System.gc();
            //因为finalize方法优先级很低,所以暂停0.5秒等待
            Thread.sleep(500);
            if(SAVE_HOOK != null){
                SAVE_HOOK.isAlive();
            }else{
                System.out.println("dead");
            }
        }
    }

运行结果:executed  alive  dead
SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在被收集前成功逃脱了。
两段完全一样的代码,第一次逃脱,第二次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。

方法区的回收
  • 一种是无用的常量,没有任何对象引用常量池的这个常量,那就会被清理。
  • 另一种是无用的类,满足该类所有的实力已经被回收,加载该类的ClassLoader已经被回收,该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还使用了-verbose:class以及-XX:+TraceClassLoading/-XX:+TraceClassUnLoading查看类加载和卸载信息。
在大量使用反射、动态代理、CGLIB等ByteCode框架,动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

垃圾收集算法

标记-清除算法:首先标记出所有需要回收的对象,然后回收。属于最基础的算法,后续都是基于这种算法的不足进行改进。主要不足有两个,一效率问题,标记和清除两个过程的效率都不高;二空间问题,标记清除之后会产生大量不连续的内存碎片,导致以后程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法:将可用内存按照容量划分为大小相等的两块,每次只使用其中一块。当一块内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。

将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor,当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被浪费。

标记-整理算法:复制收集算法在对象存活率较高时就要进行较多的复制操作,效率会变低。更关键的是如果不想浪费50%的空间,就需要额外的空间进行担保,以应对被使用的内存中的对象100%存活的极端情况。
同标记-清除算法的区别是后续步骤不是直接对可回收对象进行整理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法:根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代中每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为存活率高、没有额外空间进行分配担保,就必须使用“标记-清理”或者“标记-整理”来进行回收。

HotSpot的算法实现

枚举根节点
安全点
安全区域

垃圾收集器

内存回收的具体实现。
JDK1.7Update14之后的HotSpot虚拟机(G1收集器)

上图展示了7种不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。
Serial收集器:最基本发展历史最基本的收集器。单线程收集器,单线程的意义就是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。
虽然用户线程停顿时间无法完全消除,但它依然是虚拟机运行在Client模式下默认新生代收集器,因为对于限定单个CPU的环境来说Serial收集器没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程效率。

ParNew收集器:多线程版本,除了使用多条线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数(-XX:SurvivorRatio  -XX:PretenureSizeThreshold  -XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等于Serial收集器完全一样。
多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因,除了Serial收集器外,目前只有它能与CMS收集器(JDK1.5)配合工作。
-XX:+UseConcMarkSweepGC选项后的默认新生代收集器,也可以使用-XX:+UseParNewGC选项来强制指定它。
服务器超过32个逻辑的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

并行和并发收集器
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户线程在继续运行,而垃圾收集程序运行于另一个CPU上。

Parallel Scavenge收集器:新生代收集器,使用复制算法并行的多线程收集器。目标是达到一个可控制的吞吐量(运行用户代码的时间/运行用户代码的时间与垃圾收集时间之和)
该收集器提供两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐大小-XX:GCTimeRatio参数。
收集器尽可能保证内存回收花费的时间不超过设定值

Serial Old收集器:是Serial收集器的老年代版本,单线程收集器,使用“标记-整理”算法,在于给Client模式下的虚拟机使用。在Server模式下:一在JDK1.5以及之前的版本中与ParallelScavenge收集器搭配使用,二作为CMS收集器的后备预案,并在并发收集发生Concurrent Mode Failure时使用。


Parallel Old收集器:是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。JDK1.6中开始提供。之前如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器别无选择,造成服务端应用性能上的“拖累”。应用在注重吞吐量以及CPU资源敏感的场合,都可适用考虑Parallel Scavenge加Parallel Old收集器。


CMS收集器:获取最短回收停顿时间为目标的收集器。应用于互联网站或者B/S系统的服务端上,这类应用于尤其重视服务的响应速度,在希望停顿时间最短。
标记-清除算法实现:
  1. 初始标记 标记GC Roots能直接关联到的对象,速度很快。
  2. 并发标记 进行GC Roots Tracing的过程。
  3. 重新标记 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间比初始标记长,比并发标记短。
  4. 并发清除

缺点:
  • CMS收集器对CPU资源非常敏感,并发阶段占用一部分线程,特别是CPU不足4个,导致执行速度大幅下降。解决方法是提供了一种增量式并发收集器,单CPU系统使用抢占式模拟多任务机制,就是在并发标记、清理的时候让GC线程和用户线程交替运行,尽量减少GC线程的独占资源。但由于效果一般,i-CMS已经被声明为“deprecated”,不再提倡被用户使用。
  • CMS收集器无法和处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次“Full GC”的产生。,所以为浮动垃圾就是在收集器标记的同时用户线程依然执行产生新的垃圾,收集器当次无法收集处理,只好留待下一次GC时再清理掉。-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比(JDK1.5默认68%,JDK1.6提升92%)
  • 标记-清除算法实现的收集器会产生大量的空间碎片,对大对象的分配有很大的麻烦。CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认开启)用于在收集器顶不住的时候进行FullGC时开启内存碎片的合并整理过程。但新的问题又上来了,内存整理的过程无法并发,停顿时间又拉长了。所以又提供了另外一个参数:-XX:CMSFullGCsBeforeCompaction,这个参数是用于设计执行多少次不压缩的Gull GC后,跟着来一次压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)
G1收集器:面向服务端应用的垃圾收集器。未来可以替换掉JDK1.5中发布的CMS收集器。
特点如下:
  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java继续执行。
  • 分代收集:独立管理整个GC堆,能够采用不同的方式处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果
  • 空间整合:与CMS的标记-清理算法不同,G1从整体来看是基于标记-整理算法实现的收集器,从局部来看是基于复制算法实现的,意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存,这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC
  • 可预测的停顿:G1和CMS共同追求的降低停顿,但G1还建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
G1之前的收集器范围都是整个新生代或者老年代,而G1是将整个Java堆划分为多个大小相等的独立区域,新生代和老年代不再是物理隔离的。建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。
G1收集器运算步骤
  • 初始标记 只是标记GC Roots能直接关联到的对象,并且修改TAMS(Next Top an Mark Start)的值,让下一个阶段用户并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,耗时很短。
  • 并发标记 从GC Root开始对堆中对象进行可达性分析,找出存活的对象,耗时较长,但可以与用户程序并发执行
  • 最终标记 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logsd的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
  • 筛选回收 首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。


GC日志:

不同的收集器实现的日志格式不一样,但虚拟机设计者为了方便阅读设计了一定的共性

  • GC发生的时间:从Java虚拟机启动以来经过的秒数
  • 垃圾收集的停顿类型:Full GC表示Stop-The-World的
  • GC发生的区域:DefNew Tenured  Perm这里显示的区域名称与使用的GC收集器是密切相关的
  • 后面的3324K->152K:GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)
  • 方括号之外的3324K->152K表示GC前Java堆已使用容量->GC后Java堆已使用容量
总结:


内存分配与回收策略

Java体系中自动内存管理最终归结两个问题:
  • 给对象分配内存
  • 回收分配给对象的内存
虚拟机中的垃圾收集器以及运作原理就是回收内存。
给对象分配内存,就是在堆上分配(也可能经过JIT编译后被拆散为标量类型并间接地栈上分配)

对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪种垃圾收集器组合还有虚拟机中与内存相关的参数设置。
大对象进入老年代,需要大量连续内存空间的Java对象,很长的字符串以及数组。
长期存活的对象进入老年代(在Survivor区每熬过一次MinorGC,年龄就增加1岁,默认是15岁)晋升到老年代
动态对象年龄判定,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。

空间分配担保:
发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败,如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC.

虚拟机性能监控与故障处理工具

给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。
数据包括:运行日志、异常堆栈、GC日志、线程快照(threaddump/javacore)、堆转储快照(heapdumo/hprof文件)等

JDK的命令行工具

JDK的bin目录中有java.exe、javac.exe这两个命令行工具,JDK每次更新版本,被bin目录下命令行工具的数量和功能总会不知不觉地增加和增强。
bin目录下的命令行工具的主要功能实现是tools类,当应用程序部署到生产环境后,无论是直接接触物理服务器还是远程Telnet到服务器上都可能会受到限制。借助tools.jar类库里面的接口,我们可以直接在应用程序中实现功能强大的监控分析功能。




jps:虚拟机进程状况工具  jps -l
jps可以通过RMI协议查询开启了RMI服务的远程虚拟机进程状态,hostid为RMI注册表中注册的主机名。

jstat:虚拟机统计信息监视工具,用于监视虚拟机各种运行状态信息命令行工具。

jinfo:java配置信息工具
jmap:Java内置内存映像工具
jhat:虚拟机对转储快照分析工具
jstack:Java堆栈跟踪工具
hsdis:jit生成代码反汇编工具

JDK的可视化工具:

jconsole:Java监视与管理控制台
visualvm:多合一故障处理工具

调优场景案例分析与实战

案例一:高性能硬件上的程序部署

硬件系统升级为4个CPU、16G物理内存,64位操作系统CentOS5.4的生产环境
64为的JDK1.5,通过-Xmx  -Xms参数将堆固定为12G
效果结论:网站不定期出现长时间失去响应的情况。
原因
  • GC停顿,虚拟机运行在Server模式,默认使用吞吐量优先收集器,回收12G的堆,一次Full GC的停顿高达14秒。
  • 程序设计问题,从磁盘读到内存导致内存出现大量序列化产生的大对象,这些大对象进入老年代,没有在MinorGC中清理掉。内存很快被消耗殆尽。
  • 过大的堆内存导致回收长时间的停顿
高性能硬件上部署程序,目前有两种方式
  1. 通过64位JDK来使用大内存
  2. 使用若干个32位虚拟机建立逻辑集群来利用硬件资源
两种方式各有弊端:
  1. 内存回收导致长时间停顿;64位JDK性能测试普遍低于32位;保证程序足够稳定,如果产生堆溢出就无法产生堆转储快照,哪怕产生了也无法分析;64位JDK指针膨胀,数据类型对齐补白等因素导致消耗内存更大
  2. 节点竞争全局资源(磁盘竞争导致IO异常),很难高效率利用连接池;各个节点受到32位的内存限制;大量使用本地缓存在逻辑集群中造成较大内存浪费,因为每个逻辑节点都有一份缓存,可以考虑把本地缓存改为集中式缓存

案例二:集群间同步导致的内存溢出

B/S的MIS系统,硬件为2台个CPU、8G内存的小型机,服务器为WebLogic9.2,每台机器启动了3个WebLogic实例,构成一个6个节点的亲和式集群。
结论效果:开始读写数据存放的数据库中,由于读写频繁竞争激烈,使用JBossCache构建了一个全局缓存,全局缓存启用后,服务正常使用了一段较长时间,但最近不定期出现了多次内存溢出问题。

原因:

通过-XX:HeapDumpOnOutOfMemoryError参数运行了一段时间,在最近一次溢出之后,管理员发回了heapdumo文件,发现存在大量的org.jgroup.protocols.pbcast.NAKACK对象。
重发数据在内存不断堆积,所以产生溢出。JBossCache的缺陷和MIS系统实现方式的缺陷。
垃圾收集进行时,虚拟机虽然会对Derect Memory进行回收,但是DirectMemory却不能像新生代、老年代那样,发现空间不足就通知收集器进行垃圾回收,它只能等老年代满了后Full GC,然后顺便地帮它清理掉内存的废气对象。

此外还有以下原因案例导致的缓慢:
外部命令导致系统缓慢
服务器jvm进程崩溃
不恰当数据结构导致内存占用过大
由window虚拟内存导致的长时间停顿

ecplise运行速度调优:

  1. 收集调优前的程序数据
  2. 升级JDK虚拟机性能
  3. 编译时间和类加载时间的优化
  4. 调整内存设置控制垃圾收集频率
  5. 选择收集器降低延迟