Java的GC,类加载机制

JVM的内存结构

  根据JVM规范,JVM把内存划分成了如下几个区域:

1.方法区(Method Area)

方法区存放了要加载的类的信息(如类名、修饰符等)、静态变量、构造函数、final定义的常量、类中的字段和方法等信息。方法区是全局共享的,在一定条件下也会被GC。当方法区超过它允许的大小时,就会抛出OutOfMemory:PermGen Space异常。

 

在Hotspot虚拟机中,这块区域对应持久代(Permanent Generation),一般来说,方法区上执行GC的情况很少,因此方法区被称为持久代的原因之一,但这并不代表方法区上完全没有GC,其上的GC主要针对常量池的回收和已加载类的卸载。在方法区上进行GC,条件相当苛刻而且困难。

 

运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存储编译器生成的常量和引用。一般来说,常量的分配在编译时就能确定,但也不全是,也可以存储在运行时期产生的常量。比如String类的intern()方法,作用是String类维护了一个常量池,如果调用的字符”hello”已经在常量池中,则直接返回常量池中的地址,否则新建一个常量加入池中,并返回地址。

2.堆区(Heap)

       堆区是GC最频繁的,也是理解GC机制最重要的区域。堆区由所有线程共享,在虚拟机启动时创建。堆区主要用于存放对象实例及数组,所有new出来的对象都存储在该区域。

3.虚拟机栈(VM Stack)

       虚拟机栈占用的是操作系统内存,每个线程对应一个虚拟机栈,它是线程私有的,生命周期和线程一样,每个方法被执行时产生一个栈帧(Statck Frame),栈帧用于存储局部变量表、动态链接、操作数和方法出口等信息,当方法被调用时,栈帧入栈,当方法调用结束时,栈帧出栈。

 

局部变量表中存储着方法相关的局部变量,包括各种基本数据类型及对象的引用地址等,因此他有个特点:内存空间可以在编译期间就确定,运行时不再改变。

 

虚拟机栈定义了两种异常类型:*Error(栈溢出)和OutOfMemoryError(内存溢出)。如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出*Error;不过大多数虚拟机都允许动态扩展虚拟机栈的大小,所以线程可以一直申请栈,直到内存不足时,抛出OutOfMemoryError。

4.本地方法栈(Native Method Stack)

        本地方法栈用于支持native(关键字)方法(本地方法)的执行,存储了每个native方法的执行状态。本地方法栈和虚拟机栈他们的运行机制一致,唯一的区别是,虚拟机栈执行Java方法,本地方法栈执行native方法。在很多虚拟机中(如Sun的JDK默认的HotSpot虚拟机),会将虚拟机栈和本地方法栈一起使用。

5.程序计数器(Program Counter Register)

        程序计数器是一个很小的内存区域,不在RAM上,而是直接划分在CPU上,程序猿无法操作它,它的作用是:JVM在解释字节码(.class)文件时,存储当前线程执行的字节码行号,只是一种概念模型,各种JVM所采用的方式不一样。字节码解释器工作时,就是通过改变程序计数器的值来取下一条要执行的指令,分支、循环、跳转等基础功能都是依赖此技术区完成的。

 

每个程序计数器只能记录一个线程的行号,因此它是线程私有的。

 

如果程序当前正在执行的是一个java方法,则程序计数器记录的是正在执行的虚拟机字节码指令地址,如果执行的是native方法,则计数器的值为空,此内存区是唯一不会抛出OutOfMemoryError的区域。

JVM类加载过程

类被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段,其中验证、准备、解析3个部分统称为连接。

1.加载

加载指的是将类的class文件读入到内存,并为之创建一个java.lang.Class对象,也就是说,当程序中使用任何类时,系统都会为之建立一个java.lang.Class对象。

 

    类的加载由类加载器完成,类加载器通常由JVM提供,这些类加载器也是前面所有程序运行的基础,JVM提供的这些类加载器通常被称为系统类加载器。除此之外,开发者可以通过继承ClassLoader基类来创建自己的类加载器。

 

    通过使用不同的类加载器,可以从不同来源加载类的二进制数据,通常有如下几种来源。

 

  • 从本地文件系统加载class文件,这是前面绝大部分示例程序的类加载方式。
  • 从JAR包加载class文件,这种方式也是很常见的,前面介绍JDBC编程时用到的数据库驱动类就放在JAR文件中,JVM可以从JAR文件中直接加载该class文件。
  • 通过网络加载class文件,最典型应用就是Applet。
  • 把一个Java源文件动态编译,并执行加载。

   类加载器通常无须等到“首次使用”该类时才加载该类,Java虚拟机规范允许系统预先加载某些类。

2.连接

当类被加载之后,系统为之生成一个对应的Class对象,接着将会进入连接阶段,连接阶段负责把类的二进制数据合并到JRE中。类连接又可分为如下3个阶段。

2.1.验证

验证阶段用于检验被加载的类是否有正确的内部结构,并和其他类协调一致。其主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。 

2.2.准备

类准备阶段负责为类的静态变量分配内存,并设置默认初始值。

2.3.解析

将类的二进制数据中的符号引用替换成直接引用。说明一下:符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。布局和内存无关。直接引用:是指向目标的指针,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定加载进来的。

3.初始化

初始化是为类的静态变量赋予正确的初始值,准备阶段和初始化阶段看似有点矛盾,其实是不矛盾的,如果类中有语句:private static int a = 10,它的执行过程是这样的,首先字节码文件被加载到内存后,先进行链接的验证这一步骤,验证通过后准备阶段,给a分配内存,因为变量a是static的,所以此时a等于int类型的默认初始值0,即a=0,然后到解析(后面在说),到初始化这一步骤时,才把a的真正的值10赋给a,此时a=10。

4.类加载的时机

  • 创建类的实例,也就是new一个对象
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(Class.forName("com.lyj.load"))
  • 初始化一个类的子类(会首先初始化子类的父类)
  • JVM启动时标明的启动类,即文件名和类名相同的那个类   

5.类加载器

  • 根类加载器
  • 扩展类加载器
  • 系统类加载器

类加载机制

  1. 全盘负责
  2. 双亲委派

工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己才想办法去完成。

    3. 缓存机制

GC机制

        随着程序的运行,内存中的实例对象、变量等占据的内存越来越多,如果不及时进行回收,会降低程序运行效率,甚至引发系统异常。

 

在上面介绍的五个内存区域中,有3个是不需要进行垃圾回收的:本地方法栈、程序计数器、虚拟机栈。因为他们的生命周期是和线程同步的,随着线程的销毁,他们占用的内存会自动释放。所以,只有方法区和堆区需要进行垃圾回收,回收的对象就是那些不存在任何引用的对象。

 

1.查找算法(判断对象是否存活)

经典的引用计数算法,每个对象添加到引用计数器,每被引用一次,计数器+1,失去引用,计数器-1,当计数器在一段时间内为0时,即认为该对象可以被回收了。但是这个算法有个明显的缺陷:当两个对象相互引用,但是二者都已经没有作用时,理应把它们都回收,但是由于它们相互引用,不符合垃圾回收的条件,所以就导致无法处理掉这一块内存区域。主流java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因时它很难解决对象之间相互循环引用的问题。

因此,Sun的JVM并没有采用这种算法,而是采用一个叫——根搜索算法(可达性分析),如图:

 

       基本思想是:从一个叫GC Roots的根节点出发,向下搜索,如果一个对象不能达到GC Roots的时候,说明该对象不再被引用,可以被回收。如上图中的Object5、Object6、Object7,虽然它们三个依然相互引用,但是它们其实已经没有作用了,这样就解决了引用计数算法的缺陷。

 

       补充概念,在JDK1.2之后引入了四个概念:强引用、软引用、弱引用、虚引用。

       强引用:new出来的对象都是强引用,GC无论如何都不会回收,即使抛出OOM异常。

       软引用:只有当JVM内存不足时才会被回收。

       弱引用:只要GC,就会立马回收,不管内存是否充足。

       虚引用:可以忽略不计,JVM完全不会在乎虚引用,你可以理解为它是来凑数的,凑够”四大天王”。它唯一的作用就是做一些跟踪记录,辅助finalize函数的使用。

 

最后总结,什么样的类需要被回收:

 

a.该类的所有实例都已经被回收;

b.加载该类的ClassLoad已经被回收;

c.该类对应的反射类java.lang.Class对象没有被任何地方引用。

1.1.生存还是死亡

       要真正宣告一个对象死亡,至少要经过两次标记过程:在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。

      

       如果对象重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那么第二次标记时它将被已出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就被真的被回收了。

 

2.内存分区

        内存主要被分为三块:新生代(Youn Generation)、旧生代(Old Generation)、持久代(Permanent Generation)。三代的特点不同,造就了他们使用的GC算法不同,新生代适合生命周期较短,快速创建和销毁的对象,旧生代适合生命周期较长的对象,持久代在Sun Hotpot虚拟机中就是指方法区(有些JVM根本就没有持久代这一说法)。

 

新生代(Youn Generation):大致分为Eden区和Survivor区,Survivor区又分为大小相同的两部分:FromSpace和ToSpace。新建的对象都是从新生代分配内存,Eden区不足的时候,会把存活的对象转移到Survivor区。当新生代进行垃圾回收时会出发Minor GC(也称作Youn GC)。

 

旧生代(Old Generation):旧生代用于存放新生代多次回收依然存活的对象,如缓存对象。当旧生代满了的时候就需要对旧生代进行回收,旧生代的垃圾回收称作Major GC(也称作Full GC)。

 

持久代(Permanent Generation):在Sun 的JVM中就是方法区的意思,尽管大多数JVM没有这一代。

3.1.判断对象是否存活算法

引用计数算法、可达性分析算法、

 

引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

       主流java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因时它很难解决对象之间相互循环引用的问题。

 

       可达性分析算法:通过一系列的称为“GC Roots”的对象作为起始点。从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链路相连(就是从GC Roots 到这个对象不可达)时,则证明此对象是不可用的,所以它们会被判定为是可回收的对象。。,   

4.GC算法

常见的GC算法:复制、标记-清除和标记-压缩 分代收集算法

 

标记-清除:该算法采用的方式是从跟集合开始扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,并进行清除。

 

在Marking阶段,需要进行全盘扫描,这个过程是比较耗时的。

 

清除阶段清理的是没有被引用的对象,存活的对象被保留。

 

标记-清除动作不需要移动对象,且仅对不存活的对象进行清理,在空间中存活对象较多的时候,效率较高,但由于只是清除,没有重新整理,因此会造成内存碎片。

 

复制:复制算法采用的方式为从根集合进行扫描,将存活的对象移动到一块空闲的区域,如图所示:

 

当存活的对象较少时,复制算法会比较高效(新生代的Eden区就是采用这种算法),其带来的成本是需要一块额外的空闲空间和对象的移动。

 

标记-压缩(标记-整理):该算法与标记-清除算法类似,都是先对存活的对象进行标记,但是在清除后会把活的对象向左端空闲空间移动,然后再更新其引用对象的指针,如下图所示

 

由于进行了移动规整动作,该算法避免了标记-清除的碎片问题,但由于需要进行移动,因此成本也增加了。(该算法适用于旧生代)

 

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

 

 

Java的GC,类加载机制

Serial收集器

 Serial(串行)垃圾收集器是最基本、发展历史最悠久的收集器;

       JDK1.3.1前是HotSpot新生代收集的唯一选择;

1、特点

      针对新生代;

      采用复制算法;

      单线程收集;

       进行垃圾收集时,必须暂停所有工作线程,直到完成;            

       即会"Stop The World";

      Serial/Serial Old组合收集器运行示意图如下:

Java的GC,类加载机制

 JVM在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉,即GC停顿;

      会带给用户不良的体验;

      从JDK1.3到现在,从Serial收集器-》Parallel收集器-》CMS-》G1,用户线程停顿时间不断缩短,但仍然无法完全消除;

CMS收集器

并发标记清理(Concurrent Mark Sweep,CMS)收集器也称为并发低停顿收集器(Concurrent Low Pause Collector)或低延迟(low-latency)垃圾收集器;

      在前面ParNew收集器曾简单介绍过其特点;

1、特点

      针对老年代;

      基于"标记-清除"算法(不进行压缩操作,产生内存碎片);            

      以获取最短回收停顿时间为目标;

      并发收集、低停顿;

      需要更多的内存(看后面的缺点);

            

      是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器;

      第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;

2、应用场景

      与用户交互较多的场景;        

      希望系统停顿时间最短,注重服务的响应速度;

      以给用户带来较好的体验;

      如常见WEB、B/S系统的服务器上的应用;

3、设置参数

      "-XX:+UseConcMarkSweepGC":指定使用CMS收集器;

4、CMS收集器运作过程

      比前面几种收集器更复杂,可以分为4个步骤:

(A)、初始标记(CMS initial mark)

      仅标记一下GC Roots能直接关联到的对象;

      速度很快;

      但需要"Stop The World";

(B)、并发标记(CMS concurrent mark)

      进行GC Roots Tracing的过程;

      刚才产生的集合中标记出存活对象;

      应用程序也在运行;

      并不能保证可以标记出所有的存活对象;

(C)、重新标记(CMS remark)

      为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;

      需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;

      采用多线程并行执行来提升效率;

(D)、并发清除(CMS concurrent sweep)

      回收所有的垃圾对象;

   整个过程中耗时最长的并发标记和并发清除都可以与用户线程一起工作;

      所以总体上说,CMS收集器的内存回收过程与用户线程一起并发执行;

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

Java的GC,类加载机制

5、CMS收集器3个明显的缺点

(A)、对CPU资源非常敏感:并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。

(B)、无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败

(C)、产生大量内存碎片

 总体来看,与Parallel Old垃圾收集器相比,CMS减少了执行老年代垃圾收集时应用暂停的时间;但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间;

G1收集器:

G1(Garbage-First)是JDK7-u4才推出商用的收集器;

1、特点

(A)、并行与并发

      能充分利用多CPU、多核环境下的硬件优势;

      可以并行来缩短"Stop The World"停顿时间;

      也可以并发让垃圾收集与用户程序同时进行;

(B)、分代收集,收集范围包括新生代和老年代    

      能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;

      能够采用不同方式处理不同时期的对象;

2、应用场景

(1)、超过50%的Java堆被活动数据占用;

(2)、对象分配频率或年代提升频率变化很大;

(3)、GC停顿时间过长(长于0.5至1秒)。