JVM 高级特性

JVM内存模型及垃圾回收机制 JVM内存模型 在JVM运行的时候所分配内存区的结构,分为程序计数器、虚拟机栈、本地方法栈、堆和方法区五部分。JVM占用的内存分为线程共享区 和线程私有区,线程共享区和JVM同生共死,所有线程均可访问此区域;而线程私有区顾名思义每个线程各自占有,与各自线程同生共死。 方法区:用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,具体放在哪里,不同的实现可以放在不同的地方。 堆:Java堆是Java虚拟机所管理的内存中中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。 阅读详情

1、JVM体系结构

线程共享内存

可以被所有线程共享的区域,包括堆区、方法区、运行时常量池。

1.1 堆(Heap)

大多数时候,Java 堆是 Java 虚拟机管理的内存里最大的一块,所有的对象实例和数组都要在堆上分配内存空间,Java 对象可以分为两类,一类是快速创建快速消亡的,另一类是长期使用的。所以针对这种情况大多收集器都是基于分代收集算法进行回收。

Java 的堆可以分为新生代(Young Generation)和老年代(Old Generation),而新生代(Young Generation)又可以分为 Eden Space 空间 (伊甸园区)、From Survivor 空间(From 生存区)、To Survivor 空间(To 生存区)。

Java 堆是一块共享的区域,会出现线程安全的问题,而操作共享区域就需要锁和同步,通过- Xms设置堆的最小值,堆内存越小越容易发生内存不够用的情况而触犯 Full GC(对新生代、老年代、永久代进行垃圾回收)。官方推荐新生代大小占整个堆大小的 3/8,通过- Xmx设置堆的最大值,堆内存超过此值会发抛出 OutOfMemoryError 异常:

1.2 方法区(Method Area)

方法区(Method Area)在 HotSpot 虚拟机上可以看作是永久代(Permanent Generation),对于其他虚拟机(JRockit 、J9 等)来说是不存在永久代的。方法区也是所有线程共享的区域,主要存储被虚拟机加载的类信息、常量、静态变量,堆存储对象数据,方法区存储静态信息。

方法区不像 Java 堆区那样经常发生垃圾回收,但不表示不会发生。永久代的大小跟新生代、老年代比都是很小的,通过设置- XX:MaxPermSize来指定最大内存,方法区需要的内存超过此值会抛出 OutOfMemoryError 异常。

1.3 运行时常量池(Runtime Constant Pool)

Java 通过类加载机制可以把字节码文件中的常量池表加载进运行时常量池,而我们也可使用 String 类的 intern() 方法在运行期将新的常量放入池中,运行时常量池是方法区的一部分,在 JDK1.7 的 HotSpot 中,已经把原本放在方法区的常量池移出来了。

线程私有内存

只允许被所属的线程私自访问的内存区,包括 PC 寄存器、Java 栈和本地方法栈。

1.4 栈(Java Stack)

Java Stack 描述的是 Java 方法执行时的内存模型,每个方法执行时都会创建一个栈帧(Stack Frame),栈帧包含局部变量表(存放编译期间的各种基本数据类型,对象引用等信息)、操作数栈、动态链接、方法出口等数据。

一个线程运行时会分配栈空间,每个线程的栈空间数据是相互隔离的,所以栈是私有的,堆是共享的,一个线程执行多个方法,会入栈出栈多个栈帧(多个方法),栈是先进后出的数据结构,最先入栈的栈帧,最后出栈,可以通过-Xss设置每个线程栈的大小,越小,能创建的线程数就越多,但并不是可以无限的,在一个进程里(JVM 进程)能生成的线程数最多不超过五千

1.5 本地方法栈(Native Stack)

虚拟机栈(Java Stack)为执行 Java 方法(就是字节码)服务,而本地方法栈(Native Stack)则为 Native 方法(比如用 C/C++ 编写的代码)服务,其他方面都很类似。

1.6 PC 寄存器(程序计数器)

JVM 字节码解析器通过改变 PC 寄存器的值来明确下一条需要执行的字节码指令,每个线程都会分配一个独立的 PC 寄存器。

2、JVM 垃圾回收算法

JVM 垃圾收集算法不同虚拟机的具体实现会不一样,这里先讲解几种经典的垃圾收集算法的思想,后面再以使用得最广泛的 HotSpot 虚拟机为例讲解具体的垃圾收集器算法。

2.1 引用计数法

给每个对象维护一个引用计数器,每当被引用一次就加 1,每当引用失效一次就减 1,引用计数器为 0,表明当前对象没有被任何对象引用,则可以当作垃圾回收。但是当 A 对象和 B 对象相互引用对方的时候,大家的计数器值都不为 0,而如果对象 A 和对象 B 都已经不被外部引用,就是说两个无用的对象因为相互引用而无法进行垃圾回收。这就是循环引用的缺陷,故现在 JVM 虚拟机大多不采用这种方式做垃圾回收。

2.2 根搜索算法(Tracing)

  • 复制 (Coping)

  • 标记-清除 (Mark-Sweep)

  • 标记-压缩(Mark-Compact)

  • 分代收集算法(Generational Collection)

根搜索算法从那些被称为 GC Roots 的对象(虚拟机栈中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈 JNI 引用的对象)作为起始节点,从这些节点向下搜索,搜索所形成的路径叫引用链。当一个对象到 GC Roots 的所有对象都没有一条引用链,则说明该对象不可用,所以根搜索算法又叫可达性算法,GC Roots 到该对象不可达则表明该对象不可用,表明该对象可回收。

根搜索算法有四种,其中复制算法应用在新生代。

2.2.1 复制算法

复制算法将内存划分相等的两块,当一块内存用完了,将还存活的对象移动到另一块内存,将之前使用的内存一次清理,新生代的内存空间通常都是所有代里最大的,适用复制算法,实际上垃圾回收时是把 Eden Space 和 From Survivor 上还存活的对象复制到 To Survivor,而不需要按照 1:1 的比例来划分。

通常 Eden Space:From Survivor:To Survivor = 8:1:1,如果出现状况 To Survivor 空间不足以容纳复制过来的还存活的对象,那通过分配担保机制,这些对象可直接进入老年代,然后下一次垃圾回收发生时 From Survivor 和 To Survivor 交换身份,内存直接从 To Survivor 分配,回收到 From Survivor。

  • 优点:没有标记和清除的过程,效率高,没有内存碎片,可以利用 Bump-the-pointer(指针碰撞)技术实现快速内存分配,因为已用和未用的内存各自一边,内存分布规整有序,当新对象分配时就可以通过修改指针偏移量将其分配在第一个空闲的内存位置上,从而快速分配内存,否则只能使用空闲列表(Free List)方式分配内存,如下面要讲的标记-清除(Mark-Sweep)垃圾回收算法。

  • 缺点:开辟专门的空间存放存活对象,占用更多的内存。

2.2.2 标记-清除(Mark-Sweep)

上面的内存图示是为了理解,其实内存是线性的。

从根集合开始扫描,对存活动对象进行标记,然后重新扫描整个内存空间清除未被标记的对象,优点是不需要额外的空间,缺点是重复扫描,性能低,而且产生内存碎片。

2.2.3 标记-压缩(Mark-Compact)

一样是从从根集合开始扫描,对存活动对象进行标记,然后重新扫描整个内存空间,并往一个方向移动存活对象,虽然移动对象的消耗时间,但不产生内存碎片,可以通过 Bump-the-pointer(指针碰撞)快速分配内存。

2.2.4 标记-清除-压缩

是“标记-清除”和“标记-压缩”算法的结合,唯一的不同是要等“标记-清除”多次以后,也就是多次垃圾回收进行以后才进行移动对象(压缩),以避免每次 GC(垃圾回收)后都压缩一次,降低了移动对象的耗时。

JVM 几种经典的垃圾回收算法已经讲完,下面直接进入 HotSpot 虚拟机的讲解。

2.3 HotSpot 内存分配

2.3.1 内存分配实例

public class Earth 
{   String name;    // 单位:亿年   int age;   String size;    
   public Earth(String name, int age, String size)
          {        super();
                   this.name = name;
                   this.age = age;        
                   this.size = size;           
           }   Earth e = new Earth("地球", 46, "1.0832×10^12立方千米"); 
   }

当我们去 new 一个对象时,首先会在栈空间为对象的引用 e 分配内存,这是声明 Earth e,但由于 Earth 还是一个空对象,无法使用,不指向任何实体,接着 new Earth 会在堆开辟内存给成员变量 name、age、size,并初始化为各个数据类型的默认值,然后才是初始化为自定义的赋值,接着调用构造函数通过参数列表 (“地球”, 46, “1.0832×10^12 立方千米”) 为成员变量再赋值,最后返回对象的引用(首地址)给变量 e。

   Earth e1 = new Earth("地球", 46, "1.0832×10^12立方千米");   
   Earth e2 = new Earth("地球", 46, "1.0832×10^12立方千米");   
   Earth e3 = new Earth("地球", 46, "1.0832×10^12立方千米");

就算创建多个对象,在堆中还是一个实例,在栈中 e1、e2、e3 共同指向一个实例,而由于对象创建都是频繁而且短生命周期,故一般对象被分配在堆的新生代的 Eden 区。

而堆空间是非线程安全的,是线程共享的,为对象分配内存时就要保证原子性,防止产生脏数据,这是会消耗性能的。为此 JVM 做了优化,优先为加载完成对类在 TLAB(Thread Local Allocation,本地线程分配缓冲区)中为对象实例分配内存空间。

TLAB 是在堆中 Eden 区里开辟的空间,但却是一块线程私有区域,并不是所有对象都能在这成功分配,但在 TLAB 分配是个优选项,为了优化内存分配,还可以使用 JVM 的堆外内存存放对象实例,堆空间不是唯一 一个可以存放对象实例的地方,当一个对象作用域在方法体内,但随时间推移,一旦其引用被方法体外的成员变量引用时,就发生了逃逸;

反过来,如果作用域还是局限于方法体内,JVM 就可以为对象在栈帧里分配空间,对象分配在方法的栈帧上,随着方法创建而创建,随着方法退出而消亡,无需垃圾回收。

2.3.2 运行时常量池

我们再来看一下跟运行时常量池相关的内存分配的例子(面试常客):

public class StringDemo 
     {    
           public static void stingDemo() 
        {   // 池化的思想,把共享的数据放入字符串池中,
              以减少频繁创建销毁对象的开销           
           // 引用s1被放入栈中,字符串字面量"123"如果
              存在于字符串池中,返回其引用给s1,否则,在池中新建字符串            
           String s1 = "123";  // 池中已经存在"123",
                                  不会再创建,直接拿到引用(地址值)            
           String s2 = "123";  // 就是new对象的创建过程,不会去池查找,
                              直接在堆开辟空间存放实例对象,返回对象地址给s3                
           String s3 = new String("123");// String类本身是被final
                          修饰的,final修饰会保证s4的值不变,也就是s4=123,
                          而这个字符串字面量直接可以从池中拿到,不需要创建对象                
           String s4 = "1" + "23";       
           String str = "1";        
           // 由于被final修饰,str的值"1"不能改变,s5的值也不能变,其值是str+"23"创建对象所返回的地址(不是指对象内容       
           // 123不许变),所以这里会新建一个对象       
           String s5 = str + "23";        // 两个基本类型用==比较,比较的是两个基本类型的值是否相等       
           // 一个包装类型和一个基本类型用==比较,比较包装类型的值和基本类型的值是否相等       
           // 两个包装类型用==比较,比较两个对象返回的地址是否相等       // 两个包装类型用equals比较,比较两个对象是否相等,       
           // 包括两个对象类的类型是否相同,对象里的值是否完全相同,对象的hashcode是否相同,不比较地址,所以hashcode相同,对象不一定相等,对象相等,hashcode一定相同       
           // s1==s2:true       
           System.out.println("s1==s2:" + (s1 == s2)); // s1==s3:false       
           System.out.println("s1==s3:" + (s1 == s3)); // s1.equals(s3):true       
           // Sting类已经帮我们覆写了Object的equals方法,使得equals的比较正常,       
           // 如果没覆写,底层还是用==做的比较,我们自定义对象要用equals比较的前提是记得覆写equals方法       
           System.out.println("s1.equals(s3):" + (s1.equals(s3)));        
           // s1=s4:true       
           System.out.println("s1=s4:" + (s1 == s4));   
          }    
           public static void main(String[] args) 

         {       
           StringDemo.stingDemo();   
          }
      }
      public class TestInteger 
       {    
           public static void main(String[] args)

            {        
               int i1 = 129;        
               // java在编译的时候,会变成 Integer i2 = Integer.valueOf(129)       
               Integer i2 = 129;        
               // int和integer(无论是否new出来的)比,都为true,因为会把Integer自动拆箱为int再去比较       
               System.out.println("int i1 = 129 == Integer i2= 129 :" + (i1 == i2));       
               Integer i3 = new Integer(129);        
               // Integer与new Integer不会相等。不会经历拆箱过程,i3与i2指向是两个不同的地址,为false       
               System.out.println("Integer i2= 129 == Integer i3 = new Integer(129) :" + (i2 == i3));       
               Integer i4 = new Integer(129);   
                    
               // 两个都是new出来的,开辟不同的内存空间,都为false       
               System.out.println("Integer i3 = new Integer(129) == Integer i4 =new Integer(129) :" + (i3 == i4));       
               Integer i5 = 129;        
/*        
* Integer i2 = 129 会被编译成Integer.valueOf(129) 而valueOf的源码如下 public static        
* Integer valueOf(int i) {         * assert IntegerCache.high >= 127;        
* 如果值在-128到127之间,直接从缓存取值,不需要重新创建         
* if (i >= IntegerCache.low && i <=IntegerCache.high)         
* return IntegerCache.cache[i + (-IntegerCache.low)];         
* return new Integer(i);         * }        
*/       
// 两个都是非new出来的Integer,如果数在-128到127之间,则是true,否则为false,超过范围不会在常量池里取,会重新创建两个Integer,==比较Integer的值,即是地址,肯定false       
              System.out.println("Integer i2= 129 == Integer i5 = 129 :" + (i2 == i5));       i2 = 127;       i5 = 127;        // 在-128到127之间,从常量池取同一个引用给Integer,肯定是true       
              System.out.println("Integer i2= 127 == Integer i5 = 127 :" + (i2 == i5));   
              }
   
          }

HotSpot 内存管理里,新生代 80% 的对象生命周期较短,GC 频率高,适合采用效率较高的复制算法,经历了多次 GC 仍然存活的对象或者一些超过设定值大小的对象会分配到老年代,老年代 GC 频率较低,适合使用“标记 - 清除 - 压缩”这种综合的算法。

回收算法还有回收方式的不同,串行回收(Serial),是指就算有多个 CPU 核,某一时刻也只能有一个 CPU 核可以执行垃圾回收线程,此时用户线程会被挂起处于暂停状态,只有等回收线程执行完毕,用户线程才能继续执行,也就是会产生所谓的 Stop-the-world,JVM 短时间内卡顿不会工作。

并行回收是指某一时刻可以由多个 CPU 核同时执行各自的垃圾回收线程,不过一样会出现 Stop-the-world,而并发回收是指用户线程和垃圾回收线程交替执行,大大缩短 Stop-the-world 的停顿时间。现在大型项目动辄使用上百 G 的内存,内存越大,回收时间越久,而 Stop-the-world 的卡顿时间也会越久,目前还没有算法可以做到零停顿的。

算法的思想讲完了,下面就讲垃圾收集算法的具体实现垃圾收集器。

上面红色横线的地方就是安全点,用户线程执行时,要到达了安全点,才能暂停,让回收线程执行;当触发回收线程执行时,不会直接中断用户线程,而是设置一个标志位,让用户线程轮询。发现为中断标志时就运行到最近的安全点再将自己挂起让 CPU 执行回收线程,但如果此时用户线程处于 Waiting 或者 Blocked 状态,无法轮询标志位,就会造成回收线程长时间无法运行的情况。

为此引入了安全区,安全区就是引用关系不会发生变化的代码,在这段代码的任何地方发起 GC 都是安全的。所以当用户线程进入到安全区,恰好这时回收线程要执行就会直接中断用户线程,用户线程离开安全区时,只需检查回收线程是否已经完成,如果完成则可以离开,否则等待直到 GC 完毕。

3、HotSpot 垃圾回收器

3.1 新生代可用的垃圾回收器

Serial Coping(串行复制),Parallel Scavenge(并行复制),ParNew(并发复制)这三种回收器都是基于复制算法,复制 young eden 和 young from 中还存活的对象到 young to,或者根据设定对象的大小和 GC 次数直接晋升到 old,清空 young eden 和 young from 中的垃圾,下一次 GC 发生交换 young from 和 young to,只可使用于新生代,是在 young eden 内存空间不足以分配给对象时触发 Minor GC(新生代垃圾回收)。

3.2 老年代可用垃圾回收器

  • Serial Old (串行标记-清理-压缩)

  • Parallel Old(并行标记-压缩)

  • CMS Concurrent Mark-Sweep(并发标记清除)

3.3 垃圾收集器的组合

  • Serial Coping(串行复制)

适合客户端工作,不适合在服务器运行,针对单 CPU,小新生代,不太在乎暂停时间的应用,可通过- XX:+UseSerialGC手动指定新生代使用 Serial Coping(串行复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • ParNew(并发复制)

是 Serial Coping(串行复制)的多线程版本,在多 CPU 核情况下可以提高收集能力,但如果是单 CPU 条件下,还要来回切换任务,不一定比 Serial Coping(串行复制)收集能力强,通过- XX:+UseParNewGC手动指定新生代使用 ParNew(并发复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • Parallel Scavenge(并行复制)

跟 ParNew(并发复制)相比更注重于吞吐量而不是低延迟,如果吞吐量优先,必然会降低 GC 的频次,也就造成 GC 回收垃圾量更多、时间更长。如果低延迟优先,为了降低每次的暂停时间,就得高频的回收,这频繁的回收又会导致吞吐量的下降,所以吐吞量和低延迟是对矛盾体,适合多 CPU、高 IO 密集操作、高计算消耗的应用,通过XX:+UseParallelGC手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • Serial Old (串行标记 - 清理 - 压缩)

单线程串行回收,停顿时间长,可以使用

- XX:+PrintGCApplicationStoppedTime

查看暂停时间,适合客户端使用,不会产生内存碎片

  • Parallel Old(并行标记 - 压缩)

根据 GC 线程数划分若干区域(Region),并行做标记,重新扫描,定位到需要压缩的 Region,统计 Region 里所有存活对象的下次要移动的目的地地址,然后并行的往一端压缩,不产生内存碎片,整理后的空闲区域是连续的,通过- XX:+UseParallelOldGC手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Parallel Old(并行标记 - 压缩)收集器执行内存回收。

  • CMS Concurrent Mark-Sweep(并发标记清除)

第一阶段是初始标记,需要 Stop-the-world,这阶段标记出那些与根对象集合所连接的不可达的对象,标记完就会被暂停的应用线程;

第二阶段是并发标记,这阶段是应用线程和回收线程交替执行,把第一步标记为不可达的对象标记为垃圾对象,由于是交替进行,一开始被标记为垃圾的对象,后面应用线程可能更改对象的引用关系导致标记错误;

所以第三阶段重新标记,需要 Stop-the-world,修正上个阶段由于对象引用或者新对象创建导致的标记错误,这阶段只有回收线程执行,确保修正的正确性。

经过三个阶段的标记,第四个阶段会并发的清除无有的对象释放内存,这阶段是应用线程和回收线程交替执行,如果用户应用线程产生了新的垃圾(浮动垃圾),只能留到下次 GC 进行回收,极端情况如果产生的新的垃圾,而老年代的预留空间又不够,就会产生 Concurrent Mode Failure,这个时候只能通过后备的 Serial Old (串行标记 - 清理 - 压缩)来进行垃圾回收。

又因为 CMS 并没有用到压缩算法,回收后会产生内存碎片,为新对象分配内存无法使用 Bump-the-pointer(指针碰撞)技术实现快速内存分配,只能使用空闲列表(Free List :JVM 会维护一张可用内存地址的列表,当需要分配空间,就从列表搜索一段和对象大小一样的连续内存块用于存放要生成的对象实例)方式分配内存。

但也可以通过- XX:CMSFullGCsBeforeCompaction,用于指定经过多少次 Full GC 后对内存碎片整理压缩,由于内存碎片不是并发执行,会带来更长的停顿时间,通过- XX:+UseConcMarkSweepGC设定新生代使用 ParNew(并发复制)收集器,老年代使用 CMS Concurrent Mark-Sweep(并发标记清除)收集器执行内存回收,当出现浮动垃圾导致 Concurrent Mode Failure 或者新对象分配内存失败时,通过备用组合新生代使用 ParNew(并发复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收,适用于要求暂停时间短,追求快速响应的应用,如互联网应用。

JVM回收需要注意的点:

 

  • 在执行 Minor GC 的时候,JVM 会检查老年代中最大连续可用空间是否大于了当前新生代所有对象的总大小,如果大于,则直接执行 Minor GC;

    如果小于了,JVM 会检查是否开启了空间分配担保机制;如果开启了,则 JVM 会检查老年代中最大连续可用空间是否大于了历次晋升到老年代中的平均大小;

    如果大于则会执行 Minor GC,如果小于则执行改为执行 Full GC,如果没有开启则直接改为执行 Full GC。

  • 当老年代(Major GC)和永久代发生 GC 时,除了 CMS 外都会触发 Full GC,Full GC 就是先按新生代 GC 方式进行 Minor GC,再按照老年代的配置进行 Major GC,包含对老年代和永久代进行 GC,若 JVM 估计 Minor GC 会产生晋升失败,则会采用 Major GC 的配置进行 Full GC。

  • 如果 Minor GC 执行失败则会执行 Full GC。

  • 吞吐量:应用运行时间/总时间,暂停时间:每次 GC 造成的暂停

  • 分区分代增量式式收集器:G1(Garbage-First)收集器

传统的分代收集也提供了并发收集,但最致命的是分代收集把整个堆空间划分成固定间隔的内存块,每次收集都很容易触发 Full GC 从而扫描整个堆空间,这会拖慢应用,而且要对整个堆空间都做内存碎片整理会很麻烦。

而增量式的收集方式是一种新的收集思想,增量收集把堆空间划分成一系列的小内存块(内存块大小可配置),使用时只使用部分内存块,等这部分内存块空间不足时,再把存活对象移动到未被使用过的内存块,避免整个堆用完了再 Full GC,可以一边使用内存一边收集垃圾。

G1 收集器将整个 Java 堆区分成约 2048 大小相同的 Region 块(分新生 Region 块、幸存 Region 块、老年 Region 块),Region 块大小在 1MB 到 32MB 之间,每个对象会被分配到 Region 块里,既可以被块内对象引用也可以被块外对象引用,在判断对象是否存活时,为了避免全堆扫描或者遗漏,是通过 Remembered Set 来检查 Reference 引用的对象是否存在不同的 Region 块中的。G1 在收集垃圾时,会对各个 Region 块的回收价值和成本做排序,根据用户配置的期望停顿时间来进行回收。

G1 收集器与 CMS 收集器执行过程类似。初始标记阶段,Stop-the-World,标记 GC Roots 可直接访问到的对象,为下一个阶段并发标记时,和应用线程交替执行时,有正确可有的 Region 来分配新建对象,并发标记阶段识别上个阶段标记对象的下层对象的活跃状态,找出存活的对象,也就是标记 GC Roots 可达对象;

最终标记阶段,Stop-the-World,修正上次线程交替执行产生的变动;

清除复制阶段,Stop-the-World,这阶段并不是最终标记执行完了就一定执行,毕竟是要 Stop-the-World,为了达到准实时(可配置在 M 毫秒内最多只占用 N 毫秒的时间进行垃圾回收)会根据用户配置的 GC 时间去决定是否做清除。

还有,因为清除复制阶段使用的是复制算法,每次清理都必须保证”to space” 空间是足够的(将存活的对象复制到未使用的 Region 块),所以只有已用空间达到了(1-h)*堆大小(h 是 G1 定义的一个堆空间的百分比阈值,是个固定值)才执行清除,把存活的对象往一个方向移动到”to space” 并整理内存,不会产生内存碎片。

接着把”Eden space” “from space” 的垃圾对象清理,根据维护的优先列表,优先回收价值最大的 Region,通过五个阶段完成垃圾收集,可以通过设定 - XX:UseG1GC 在整个 Java 堆使用 G1 进行垃圾回收,G1 适合高吞吐、低延时、大堆空间的应用。

4、垃圾回收器配置和 GC 日志分析

4.1 堆典型配置:

32 位操作系统限制堆大小介于 1.5G 到 2G,64 位操作系统无限制,同时系统可用虚拟内存,可用物理内存都会限制最大堆的配置。

堆空间分配典型设置:

  • -Xms:初始堆大小

  • -Xmx:最大堆大小

  • -XX:NewSize=n:设置年轻代大小

  • -XX:NewRatio=n:设置年轻代和年老代的比值。如 n 为 2,表示年轻代与年老代比值为 1:2,年轻代占整个年轻代年老代和的 1/3

  • -XX:SurvivorRatio=n:年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。如 n 为 2,表示 Eden:Survivor=1:2,一个 Survivor 区占整个年轻代的 1/4

  • -XX:MaxPermSize=:设置持久代大小

-Xmx5120m –Xms5120m -Xmn2g -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=16m -XX:MaxTenuringThreshold=0

  • -Xmn2g:设置年轻代大小为 2G。整个 JVM 内存大小 = 年轻代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为 64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun 官方推荐配置为整个堆的 3/8。

  • -Xss128k:设置每个线程的栈大小。JDK5.0 以后每个线程堆栈大小为 1M,以前每个线程堆栈大小为 256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的在 3000~5000 间。

  • -XX:SurvivorRatio=4:设置年轻代中 Eden 区与 Survivor 区的大小比值。

  • -XX:MaxPermSize=16m:设置持久代大小为 16m。

  • -XX:MaxTenuringThreshold=0:设置年轻代最大年龄。如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在 Survivor 区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概率。

  • -XX:+DisableExplicitGC:这个将会忽略手动调用 GC 的代码使得 System.gc() 的调用就会变成一个空调用,完全不会触发任何 GC

4.2 垃圾回收器配置

1、JVM体系结构

线程共享内存

可以被所有线程共享的区域,包括堆区、方法区、运行时常量池。

1.1 堆(Heap)

大多数时候,Java 堆是 Java 虚拟机管理的内存里最大的一块,所有的对象实例和数组都要在堆上分配内存空间,Java 对象可以分为两类,一类是快速创建快速消亡的,另一类是长期使用的。所以针对这种情况大多收集器都是基于分代收集算法进行回收。

Java 的堆可以分为新生代(Young Generation)和老年代(Old Generation),而新生代(Young Generation)又可以分为 Eden Space 空间 (伊甸园区)、From Survivor 空间(From 生存区)、To Survivor 空间(To 生存区)。

Java 堆是一块共享的区域,会出现线程安全的问题,而操作共享区域就需要锁和同步,通过- Xms设置堆的最小值,堆内存越小越容易发生内存不够用的情况而触犯 Full GC(对新生代、老年代、永久代进行垃圾回收)。官方推荐新生代大小占整个堆大小的 3/8,通过- Xmx设置堆的最大值,堆内存超过此值会发抛出 OutOfMemoryError 异常:

1.2 方法区(Method Area)

方法区(Method Area)在 HotSpot 虚拟机上可以看作是永久代(Permanent Generation),对于其他虚拟机(JRockit 、J9 等)来说是不存在永久代的。方法区也是所有线程共享的区域,主要存储被虚拟机加载的类信息、常量、静态变量,堆存储对象数据,方法区存储静态信息。

方法区不像 Java 堆区那样经常发生垃圾回收,但不表示不会发生。永久代的大小跟新生代、老年代比都是很小的,通过设置- XX:MaxPermSize来指定最大内存,方法区需要的内存超过此值会抛出 OutOfMemoryError 异常。

1.3 运行时常量池(Runtime Constant Pool)

Java 通过类加载机制可以把字节码文件中的常量池表加载进运行时常量池,而我们也可使用 String 类的 intern() 方法在运行期将新的常量放入池中,运行时常量池是方法区的一部分,在 JDK1.7 的 HotSpot 中,已经把原本放在方法区的常量池移出来了。

线程私有内存

只允许被所属的线程私自访问的内存区,包括 PC 寄存器、Java 栈和本地方法栈。

1.4 栈(Java Stack)

Java Stack 描述的是 Java 方法执行时的内存模型,每个方法执行时都会创建一个栈帧(Stack Frame),栈帧包含局部变量表(存放编译期间的各种基本数据类型,对象引用等信息)、操作数栈、动态链接、方法出口等数据。

一个线程运行时会分配栈空间,每个线程的栈空间数据是相互隔离的,所以栈是私有的,堆是共享的,一个线程执行多个方法,会入栈出栈多个栈帧(多个方法),栈是先进后出的数据结构,最先入栈的栈帧,最后出栈,可以通过-Xss设置每个线程栈的大小,越小,能创建的线程数就越多,但并不是可以无限的,在一个进程里(JVM 进程)能生成的线程数最多不超过五千

1.5 本地方法栈(Native Stack)

虚拟机栈(Java Stack)为执行 Java 方法(就是字节码)服务,而本地方法栈(Native Stack)则为 Native 方法(比如用 C/C++ 编写的代码)服务,其他方面都很类似。

1.6 PC 寄存器(程序计数器)

JVM 字节码解析器通过改变 PC 寄存器的值来明确下一条需要执行的字节码指令,每个线程都会分配一个独立的 PC 寄存器。

2、JVM 垃圾回收算法

JVM 垃圾收集算法不同虚拟机的具体实现会不一样,这里先讲解几种经典的垃圾收集算法的思想,后面再以使用得最广泛的 HotSpot 虚拟机为例讲解具体的垃圾收集器算法。

2.1 引用计数法

给每个对象维护一个引用计数器,每当被引用一次就加 1,每当引用失效一次就减 1,引用计数器为 0,表明当前对象没有被任何对象引用,则可以当作垃圾回收。但是当 A 对象和 B 对象相互引用对方的时候,大家的计数器值都不为 0,而如果对象 A 和对象 B 都已经不被外部引用,就是说两个无用的对象因为相互引用而无法进行垃圾回收。这就是循环引用的缺陷,故现在 JVM 虚拟机大多不采用这种方式做垃圾回收。

2.2 根搜索算法(Tracing)

  • 复制 (Coping)

  • 标记-清除 (Mark-Sweep)

  • 标记-压缩(Mark-Compact)

  • 分代收集算法(Generational Collection)

根搜索算法从那些被称为 GC Roots 的对象(虚拟机栈中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈 JNI 引用的对象)作为起始节点,从这些节点向下搜索,搜索所形成的路径叫引用链。当一个对象到 GC Roots 的所有对象都没有一条引用链,则说明该对象不可用,所以根搜索算法又叫可达性算法,GC Roots 到该对象不可达则表明该对象不可用,表明该对象可回收。

根搜索算法有四种,其中复制算法应用在新生代。

2.2.1 复制算法

复制算法将内存划分相等的两块,当一块内存用完了,将还存活的对象移动到另一块内存,将之前使用的内存一次清理,新生代的内存空间通常都是所有代里最大的,适用复制算法,实际上垃圾回收时是把 Eden Space 和 From Survivor 上还存活的对象复制到 To Survivor,而不需要按照 1:1 的比例来划分。

通常 Eden Space:From Survivor:To Survivor = 8:1:1,如果出现状况 To Survivor 空间不足以容纳复制过来的还存活的对象,那通过分配担保机制,这些对象可直接进入老年代,然后下一次垃圾回收发生时 From Survivor 和 To Survivor 交换身份,内存直接从 To Survivor 分配,回收到 From Survivor。

  • 优点:没有标记和清除的过程,效率高,没有内存碎片,可以利用 Bump-the-pointer(指针碰撞)技术实现快速内存分配,因为已用和未用的内存各自一边,内存分布规整有序,当新对象分配时就可以通过修改指针偏移量将其分配在第一个空闲的内存位置上,从而快速分配内存,否则只能使用空闲列表(Free List)方式分配内存,如下面要讲的标记-清除(Mark-Sweep)垃圾回收算法。

  • 缺点:开辟专门的空间存放存活对象,占用更多的内存。

2.2.2 标记-清除(Mark-Sweep)

上面的内存图示是为了理解,其实内存是线性的。

从根集合开始扫描,对存活动对象进行标记,然后重新扫描整个内存空间清除未被标记的对象,优点是不需要额外的空间,缺点是重复扫描,性能低,而且产生内存碎片。

2.2.3 标记-压缩(Mark-Compact)

一样是从从根集合开始扫描,对存活动对象进行标记,然后重新扫描整个内存空间,并往一个方向移动存活对象,虽然移动对象的消耗时间,但不产生内存碎片,可以通过 Bump-the-pointer(指针碰撞)快速分配内存。

2.2.4 标记-清除-压缩

是“标记-清除”和“标记-压缩”算法的结合,唯一的不同是要等“标记-清除”多次以后,也就是多次垃圾回收进行以后才进行移动对象(压缩),以避免每次 GC(垃圾回收)后都压缩一次,降低了移动对象的耗时。

JVM 几种经典的垃圾回收算法已经讲完,下面直接进入 HotSpot 虚拟机的讲解。

2.3 HotSpot 内存分配

2.3.1 内存分配实例

public class Earth 
{   String name;    // 单位:亿年   int age;   String size;    
   public Earth(String name, int age, String size)
          {        super();
                   this.name = name;
                   this.age = age;        
                   this.size = size;           
           }   Earth e = new Earth("地球", 46, "1.0832×10^12立方千米"); 
   }

当我们去 new 一个对象时,首先会在栈空间为对象的引用 e 分配内存,这是声明 Earth e,但由于 Earth 还是一个空对象,无法使用,不指向任何实体,接着 new Earth 会在堆开辟内存给成员变量 name、age、size,并初始化为各个数据类型的默认值,然后才是初始化为自定义的赋值,接着调用构造函数通过参数列表 (“地球”, 46, “1.0832×10^12 立方千米”) 为成员变量再赋值,最后返回对象的引用(首地址)给变量 e。

   Earth e1 = new Earth("地球", 46, "1.0832×10^12立方千米");   
   Earth e2 = new Earth("地球", 46, "1.0832×10^12立方千米");   
   Earth e3 = new Earth("地球", 46, "1.0832×10^12立方千米");

就算创建多个对象,在堆中还是一个实例,在栈中 e1、e2、e3 共同指向一个实例,而由于对象创建都是频繁而且短生命周期,故一般对象被分配在堆的新生代的 Eden 区。

而堆空间是非线程安全的,是线程共享的,为对象分配内存时就要保证原子性,防止产生脏数据,这是会消耗性能的。为此 JVM 做了优化,优先为加载完成对类在 TLAB(Thread Local Allocation,本地线程分配缓冲区)中为对象实例分配内存空间。

TLAB 是在堆中 Eden 区里开辟的空间,但却是一块线程私有区域,并不是所有对象都能在这成功分配,但在 TLAB 分配是个优选项,为了优化内存分配,还可以使用 JVM 的堆外内存存放对象实例,堆空间不是唯一 一个可以存放对象实例的地方,当一个对象作用域在方法体内,但随时间推移,一旦其引用被方法体外的成员变量引用时,就发生了逃逸;

反过来,如果作用域还是局限于方法体内,JVM 就可以为对象在栈帧里分配空间,对象分配在方法的栈帧上,随着方法创建而创建,随着方法退出而消亡,无需垃圾回收。

2.3.2 运行时常量池

我们再来看一下跟运行时常量池相关的内存分配的例子(面试常客):

public class StringDemo 
     {    
           public static void stingDemo() 
        {   // 池化的思想,把共享的数据放入字符串池中,
              以减少频繁创建销毁对象的开销           
           // 引用s1被放入栈中,字符串字面量"123"如果
              存在于字符串池中,返回其引用给s1,否则,在池中新建字符串            
           String s1 = "123";  // 池中已经存在"123",
                                  不会再创建,直接拿到引用(地址值)            
           String s2 = "123";  // 就是new对象的创建过程,不会去池查找,
                              直接在堆开辟空间存放实例对象,返回对象地址给s3                
           String s3 = new String("123");// String类本身是被final
                          修饰的,final修饰会保证s4的值不变,也就是s4=123,
                          而这个字符串字面量直接可以从池中拿到,不需要创建对象                
           String s4 = "1" + "23";       
           String str = "1";        
           // 由于被final修饰,str的值"1"不能改变,s5的值也不能变,其值是str+"23"创建对象所返回的地址(不是指对象内容       
           // 123不许变),所以这里会新建一个对象       
           String s5 = str + "23";        // 两个基本类型用==比较,比较的是两个基本类型的值是否相等       
           // 一个包装类型和一个基本类型用==比较,比较包装类型的值和基本类型的值是否相等       
           // 两个包装类型用==比较,比较两个对象返回的地址是否相等       // 两个包装类型用equals比较,比较两个对象是否相等,       
           // 包括两个对象类的类型是否相同,对象里的值是否完全相同,对象的hashcode是否相同,不比较地址,所以hashcode相同,对象不一定相等,对象相等,hashcode一定相同       
           // s1==s2:true       
           System.out.println("s1==s2:" + (s1 == s2)); // s1==s3:false       
           System.out.println("s1==s3:" + (s1 == s3)); // s1.equals(s3):true       
           // Sting类已经帮我们覆写了Object的equals方法,使得equals的比较正常,       
           // 如果没覆写,底层还是用==做的比较,我们自定义对象要用equals比较的前提是记得覆写equals方法       
           System.out.println("s1.equals(s3):" + (s1.equals(s3)));        
           // s1=s4:true       
           System.out.println("s1=s4:" + (s1 == s4));   
          }    
           public static void main(String[] args) 

         {       
           StringDemo.stingDemo();   
          }
      }
      public class TestInteger 
       {    
           public static void main(String[] args)

            {        
               int i1 = 129;        
               // java在编译的时候,会变成 Integer i2 = Integer.valueOf(129)       
               Integer i2 = 129;        
               // int和integer(无论是否new出来的)比,都为true,因为会把Integer自动拆箱为int再去比较       
               System.out.println("int i1 = 129 == Integer i2= 129 :" + (i1 == i2));       
               Integer i3 = new Integer(129);        
               // Integer与new Integer不会相等。不会经历拆箱过程,i3与i2指向是两个不同的地址,为false       
               System.out.println("Integer i2= 129 == Integer i3 = new Integer(129) :" + (i2 == i3));       
               Integer i4 = new Integer(129);   
                    
               // 两个都是new出来的,开辟不同的内存空间,都为false       
               System.out.println("Integer i3 = new Integer(129) == Integer i4 =new Integer(129) :" + (i3 == i4));       
               Integer i5 = 129;        
/*        
* Integer i2 = 129 会被编译成Integer.valueOf(129) 而valueOf的源码如下 public static        
* Integer valueOf(int i) {         * assert IntegerCache.high >= 127;        
* 如果值在-128到127之间,直接从缓存取值,不需要重新创建         
* if (i >= IntegerCache.low && i <=IntegerCache.high)         
* return IntegerCache.cache[i + (-IntegerCache.low)];         
* return new Integer(i);         * }        
*/       
// 两个都是非new出来的Integer,如果数在-128到127之间,则是true,否则为false,超过范围不会在常量池里取,会重新创建两个Integer,==比较Integer的值,即是地址,肯定false       
              System.out.println("Integer i2= 129 == Integer i5 = 129 :" + (i2 == i5));       i2 = 127;       i5 = 127;        // 在-128到127之间,从常量池取同一个引用给Integer,肯定是true       
              System.out.println("Integer i2= 127 == Integer i5 = 127 :" + (i2 == i5));   
              }
   
          }

HotSpot 内存管理里,新生代 80% 的对象生命周期较短,GC 频率高,适合采用效率较高的复制算法,经历了多次 GC 仍然存活的对象或者一些超过设定值大小的对象会分配到老年代,老年代 GC 频率较低,适合使用“标记 - 清除 - 压缩”这种综合的算法。

回收算法还有回收方式的不同,串行回收(Serial),是指就算有多个 CPU 核,某一时刻也只能有一个 CPU 核可以执行垃圾回收线程,此时用户线程会被挂起处于暂停状态,只有等回收线程执行完毕,用户线程才能继续执行,也就是会产生所谓的 Stop-the-world,JVM 短时间内卡顿不会工作。

并行回收是指某一时刻可以由多个 CPU 核同时执行各自的垃圾回收线程,不过一样会出现 Stop-the-world,而并发回收是指用户线程和垃圾回收线程交替执行,大大缩短 Stop-the-world 的停顿时间。现在大型项目动辄使用上百 G 的内存,内存越大,回收时间越久,而 Stop-the-world 的卡顿时间也会越久,目前还没有算法可以做到零停顿的。

算法的思想讲完了,下面就讲垃圾收集算法的具体实现垃圾收集器。

上面红色横线的地方就是安全点,用户线程执行时,要到达了安全点,才能暂停,让回收线程执行;当触发回收线程执行时,不会直接中断用户线程,而是设置一个标志位,让用户线程轮询。发现为中断标志时就运行到最近的安全点再将自己挂起让 CPU 执行回收线程,但如果此时用户线程处于 Waiting 或者 Blocked 状态,无法轮询标志位,就会造成回收线程长时间无法运行的情况。

为此引入了安全区,安全区就是引用关系不会发生变化的代码,在这段代码的任何地方发起 GC 都是安全的。所以当用户线程进入到安全区,恰好这时回收线程要执行就会直接中断用户线程,用户线程离开安全区时,只需检查回收线程是否已经完成,如果完成则可以离开,否则等待直到 GC 完毕。

3、HotSpot 垃圾回收器

3.1 新生代可用的垃圾回收器

Serial Coping(串行复制),Parallel Scavenge(并行复制),ParNew(并发复制)这三种回收器都是基于复制算法,复制 young eden 和 young from 中还存活的对象到 young to,或者根据设定对象的大小和 GC 次数直接晋升到 old,清空 young eden 和 young from 中的垃圾,下一次 GC 发生交换 young from 和 young to,只可使用于新生代,是在 young eden 内存空间不足以分配给对象时触发 Minor GC(新生代垃圾回收)。

3.2 老年代可用垃圾回收器

  • Serial Old (串行标记-清理-压缩)

  • Parallel Old(并行标记-压缩)

  • CMS Concurrent Mark-Sweep(并发标记清除)

3.3 垃圾收集器的组合

  • Serial Coping(串行复制)

适合客户端工作,不适合在服务器运行,针对单 CPU,小新生代,不太在乎暂停时间的应用,可通过- XX:+UseSerialGC手动指定新生代使用 Serial Coping(串行复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • ParNew(并发复制)

是 Serial Coping(串行复制)的多线程版本,在多 CPU 核情况下可以提高收集能力,但如果是单 CPU 条件下,还要来回切换任务,不一定比 Serial Coping(串行复制)收集能力强,通过- XX:+UseParNewGC手动指定新生代使用 ParNew(并发复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • Parallel Scavenge(并行复制)

跟 ParNew(并发复制)相比更注重于吞吐量而不是低延迟,如果吞吐量优先,必然会降低 GC 的频次,也就造成 GC 回收垃圾量更多、时间更长。如果低延迟优先,为了降低每次的暂停时间,就得高频的回收,这频繁的回收又会导致吞吐量的下降,所以吐吞量和低延迟是对矛盾体,适合多 CPU、高 IO 密集操作、高计算消耗的应用,通过XX:+UseParallelGC手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • Serial Old (串行标记 - 清理 - 压缩)

单线程串行回收,停顿时间长,可以使用

- XX:+PrintGCApplicationStoppedTime

查看暂停时间,适合客户端使用,不会产生内存碎片

  • Parallel Old(并行标记 - 压缩)

根据 GC 线程数划分若干区域(Region),并行做标记,重新扫描,定位到需要压缩的 Region,统计 Region 里所有存活对象的下次要移动的目的地地址,然后并行的往一端压缩,不产生内存碎片,整理后的空闲区域是连续的,通过- XX:+UseParallelOldGC手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Parallel Old(并行标记 - 压缩)收集器执行内存回收。

  • CMS Concurrent Mark-Sweep(并发标记清除)

第一阶段是初始标记,需要 Stop-the-world,这阶段标记出那些与根对象集合所连接的不可达的对象,标记完就会被暂停的应用线程;

第二阶段是并发标记,这阶段是应用线程和回收线程交替执行,把第一步标记为不可达的对象标记为垃圾对象,由于是交替进行,一开始被标记为垃圾的对象,后面应用线程可能更改对象的引用关系导致标记错误;

所以第三阶段重新标记,需要 Stop-the-world,修正上个阶段由于对象引用或者新对象创建导致的标记错误,这阶段只有回收线程执行,确保修正的正确性。

经过三个阶段的标记,第四个阶段会并发的清除无有的对象释放内存,这阶段是应用线程和回收线程交替执行,如果用户应用线程产生了新的垃圾(浮动垃圾),只能留到下次 GC 进行回收,极端情况如果产生的新的垃圾,而老年代的预留空间又不够,就会产生 Concurrent Mode Failure,这个时候只能通过后备的 Serial Old (串行标记 - 清理 - 压缩)来进行垃圾回收。

又因为 CMS 并没有用到压缩算法,回收后会产生内存碎片,为新对象分配内存无法使用 Bump-the-pointer(指针碰撞)技术实现快速内存分配,只能使用空闲列表(Free List :JVM 会维护一张可用内存地址的列表,当需要分配空间,就从列表搜索一段和对象大小一样的连续内存块用于存放要生成的对象实例)方式分配内存。

但也可以通过- XX:CMSFullGCsBeforeCompaction,用于指定经过多少次 Full GC 后对内存碎片整理压缩,由于内存碎片不是并发执行,会带来更长的停顿时间,通过- XX:+UseConcMarkSweepGC设定新生代使用 ParNew(并发复制)收集器,老年代使用 CMS Concurrent Mark-Sweep(并发标记清除)收集器执行内存回收,当出现浮动垃圾导致 Concurrent Mode Failure 或者新对象分配内存失败时,通过备用组合新生代使用 ParNew(并发复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收,适用于要求暂停时间短,追求快速响应的应用,如互联网应用。

JVM回收需要注意的点:

 

  • 在执行 Minor GC 的时候,JVM 会检查老年代中最大连续可用空间是否大于了当前新生代所有对象的总大小,如果大于,则直接执行 Minor GC;

    如果小于了,JVM 会检查是否开启了空间分配担保机制;如果开启了,则 JVM 会检查老年代中最大连续可用空间是否大于了历次晋升到老年代中的平均大小;

    如果大于则会执行 Minor GC,如果小于则执行改为执行 Full GC,如果没有开启则直接改为执行 Full GC。

  • 当老年代(Major GC)和永久代发生 GC 时,除了 CMS 外都会触发 Full GC,Full GC 就是先按新生代 GC 方式进行 Minor GC,再按照老年代的配置进行 Major GC,包含对老年代和永久代进行 GC,若 JVM 估计 Minor GC 会产生晋升失败,则会采用 Major GC 的配置进行 Full GC。

  • 如果 Minor GC 执行失败则会执行 Full GC。

  • 吞吐量:应用运行时间/总时间,暂停时间:每次 GC 造成的暂停

  • 分区分代增量式式收集器:G1(Garbage-First)收集器

传统的分代收集也提供了并发收集,但最致命的是分代收集把整个堆空间划分成固定间隔的内存块,每次收集都很容易触发 Full GC 从而扫描整个堆空间,这会拖慢应用,而且要对整个堆空间都做内存碎片整理会很麻烦。

而增量式的收集方式是一种新的收集思想,增量收集把堆空间划分成一系列的小内存块(内存块大小可配置),使用时只使用部分内存块,等这部分内存块空间不足时,再把存活对象移动到未被使用过的内存块,避免整个堆用完了再 Full GC,可以一边使用内存一边收集垃圾。

G1 收集器将整个 Java 堆区分成约 2048 大小相同的 Region 块(分新生 Region 块、幸存 Region 块、老年 Region 块),Region 块大小在 1MB 到 32MB 之间,每个对象会被分配到 Region 块里,既可以被块内对象引用也可以被块外对象引用,在判断对象是否存活时,为了避免全堆扫描或者遗漏,是通过 Remembered Set 来检查 Reference 引用的对象是否存在不同的 Region 块中的。G1 在收集垃圾时,会对各个 Region 块的回收价值和成本做排序,根据用户配置的期望停顿时间来进行回收。

G1 收集器与 CMS 收集器执行过程类似。初始标记阶段,Stop-the-World,标记 GC Roots 可直接访问到的对象,为下一个阶段并发标记时,和应用线程交替执行时,有正确可有的 Region 来分配新建对象,并发标记阶段识别上个阶段标记对象的下层对象的活跃状态,找出存活的对象,也就是标记 GC Roots 可达对象;

最终标记阶段,Stop-the-World,修正上次线程交替执行产生的变动;

清除复制阶段,Stop-the-World,这阶段并不是最终标记执行完了就一定执行,毕竟是要 Stop-the-World,为了达到准实时(可配置在 M 毫秒内最多只占用 N 毫秒的时间进行垃圾回收)会根据用户配置的 GC 时间去决定是否做清除。

还有,因为清除复制阶段使用的是复制算法,每次清理都必须保证”to space” 空间是足够的(将存活的对象复制到未使用的 Region 块),所以只有已用空间达到了(1-h)*堆大小(h 是 G1 定义的一个堆空间的百分比阈值,是个固定值)才执行清除,把存活的对象往一个方向移动到”to space” 并整理内存,不会产生内存碎片。

接着把”Eden space” “from space” 的垃圾对象清理,根据维护的优先列表,优先回收价值最大的 Region,通过五个阶段完成垃圾收集,可以通过设定 - XX:UseG1GC 在整个 Java 堆使用 G1 进行垃圾回收,G1 适合高吞吐、低延时、大堆空间的应用。

4、垃圾回收器配置和 GC 日志分析

4.1 堆典型配置:

32 位操作系统限制堆大小介于 1.5G 到 2G,64 位操作系统无限制,同时系统可用虚拟内存,可用物理内存都会限制最大堆的配置。

堆空间分配典型设置:

  • -Xms:初始堆大小

  • -Xmx:最大堆大小

  • -XX:NewSize=n:设置年轻代大小

  • -XX:NewRatio=n:设置年轻代和年老代的比值。如 n 为 2,表示年轻代与年老代比值为 1:2,年轻代占整个年轻代年老代和的 1/3

  • -XX:SurvivorRatio=n:年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。如 n 为 2,表示 Eden:Survivor=1:2,一个 Survivor 区占整个年轻代的 1/4

  • -XX:MaxPermSize=:设置持久代大小

-Xmx5120m –Xms5120m -Xmn2g -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=16m -XX:MaxTenuringThreshold=0

  • -Xmn2g:设置年轻代大小为 2G。整个 JVM 内存大小 = 年轻代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为 64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun 官方推荐配置为整个堆的 3/8。

  • -Xss128k:设置每个线程的栈大小。JDK5.0 以后每个线程堆栈大小为 1M,以前每个线程堆栈大小为 256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的在 3000~5000 间。

  • -XX:SurvivorRatio=4:设置年轻代中 Eden 区与 Survivor 区的大小比值。

  • -XX:MaxPermSize=16m:设置持久代大小为 16m。

  • -XX:MaxTenuringThreshold=0:设置年轻代最大年龄。如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在 Survivor 区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概率。

  • -XX:+DisableExplicitGC:这个将会忽略手动调用 GC 的代码使得 System.gc() 的调用就会变成一个空调用,完全不会触发任何 GC

4.2 垃圾回收器配置

1、JVM体系结构

线程共享内存

可以被所有线程共享的区域,包括堆区、方法区、运行时常量池。

1.1 堆(Heap)

大多数时候,Java 堆是 Java 虚拟机管理的内存里最大的一块,所有的对象实例和数组都要在堆上分配内存空间,Java 对象可以分为两类,一类是快速创建快速消亡的,另一类是长期使用的。所以针对这种情况大多收集器都是基于分代收集算法进行回收。

Java 的堆可以分为新生代(Young Generation)和老年代(Old Generation),而新生代(Young Generation)又可以分为 Eden Space 空间 (伊甸园区)、From Survivor 空间(From 生存区)、To Survivor 空间(To 生存区)。

Java 堆是一块共享的区域,会出现线程安全的问题,而操作共享区域就需要锁和同步,通过- Xms设置堆的最小值,堆内存越小越容易发生内存不够用的情况而触犯 Full GC(对新生代、老年代、永久代进行垃圾回收)。官方推荐新生代大小占整个堆大小的 3/8,通过- Xmx设置堆的最大值,堆内存超过此值会发抛出 OutOfMemoryError 异常:

1.2 方法区(Method Area)

方法区(Method Area)在 HotSpot 虚拟机上可以看作是永久代(Permanent Generation),对于其他虚拟机(JRockit 、J9 等)来说是不存在永久代的。方法区也是所有线程共享的区域,主要存储被虚拟机加载的类信息、常量、静态变量,堆存储对象数据,方法区存储静态信息。

方法区不像 Java 堆区那样经常发生垃圾回收,但不表示不会发生。永久代的大小跟新生代、老年代比都是很小的,通过设置- XX:MaxPermSize来指定最大内存,方法区需要的内存超过此值会抛出 OutOfMemoryError 异常。

1.3 运行时常量池(Runtime Constant Pool)

Java 通过类加载机制可以把字节码文件中的常量池表加载进运行时常量池,而我们也可使用 String 类的 intern() 方法在运行期将新的常量放入池中,运行时常量池是方法区的一部分,在 JDK1.7 的 HotSpot 中,已经把原本放在方法区的常量池移出来了。

线程私有内存

只允许被所属的线程私自访问的内存区,包括 PC 寄存器、Java 栈和本地方法栈。

1.4 栈(Java Stack)

Java Stack 描述的是 Java 方法执行时的内存模型,每个方法执行时都会创建一个栈帧(Stack Frame),栈帧包含局部变量表(存放编译期间的各种基本数据类型,对象引用等信息)、操作数栈、动态链接、方法出口等数据。

一个线程运行时会分配栈空间,每个线程的栈空间数据是相互隔离的,所以栈是私有的,堆是共享的,一个线程执行多个方法,会入栈出栈多个栈帧(多个方法),栈是先进后出的数据结构,最先入栈的栈帧,最后出栈,可以通过-Xss设置每个线程栈的大小,越小,能创建的线程数就越多,但并不是可以无限的,在一个进程里(JVM 进程)能生成的线程数最多不超过五千

1.5 本地方法栈(Native Stack)

虚拟机栈(Java Stack)为执行 Java 方法(就是字节码)服务,而本地方法栈(Native Stack)则为 Native 方法(比如用 C/C++ 编写的代码)服务,其他方面都很类似。

1.6 PC 寄存器(程序计数器)

JVM 字节码解析器通过改变 PC 寄存器的值来明确下一条需要执行的字节码指令,每个线程都会分配一个独立的 PC 寄存器。

2、JVM 垃圾回收算法

JVM 垃圾收集算法不同虚拟机的具体实现会不一样,这里先讲解几种经典的垃圾收集算法的思想,后面再以使用得最广泛的 HotSpot 虚拟机为例讲解具体的垃圾收集器算法。

2.1 引用计数法

给每个对象维护一个引用计数器,每当被引用一次就加 1,每当引用失效一次就减 1,引用计数器为 0,表明当前对象没有被任何对象引用,则可以当作垃圾回收。但是当 A 对象和 B 对象相互引用对方的时候,大家的计数器值都不为 0,而如果对象 A 和对象 B 都已经不被外部引用,就是说两个无用的对象因为相互引用而无法进行垃圾回收。这就是循环引用的缺陷,故现在 JVM 虚拟机大多不采用这种方式做垃圾回收。

2.2 根搜索算法(Tracing)

  • 复制 (Coping)

  • 标记-清除 (Mark-Sweep)

  • 标记-压缩(Mark-Compact)

  • 分代收集算法(Generational Collection)

根搜索算法从那些被称为 GC Roots 的对象(虚拟机栈中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈 JNI 引用的对象)作为起始节点,从这些节点向下搜索,搜索所形成的路径叫引用链。当一个对象到 GC Roots 的所有对象都没有一条引用链,则说明该对象不可用,所以根搜索算法又叫可达性算法,GC Roots 到该对象不可达则表明该对象不可用,表明该对象可回收。

根搜索算法有四种,其中复制算法应用在新生代。

2.2.1 复制算法

复制算法将内存划分相等的两块,当一块内存用完了,将还存活的对象移动到另一块内存,将之前使用的内存一次清理,新生代的内存空间通常都是所有代里最大的,适用复制算法,实际上垃圾回收时是把 Eden Space 和 From Survivor 上还存活的对象复制到 To Survivor,而不需要按照 1:1 的比例来划分。

通常 Eden Space:From Survivor:To Survivor = 8:1:1,如果出现状况 To Survivor 空间不足以容纳复制过来的还存活的对象,那通过分配担保机制,这些对象可直接进入老年代,然后下一次垃圾回收发生时 From Survivor 和 To Survivor 交换身份,内存直接从 To Survivor 分配,回收到 From Survivor。

  • 优点:没有标记和清除的过程,效率高,没有内存碎片,可以利用 Bump-the-pointer(指针碰撞)技术实现快速内存分配,因为已用和未用的内存各自一边,内存分布规整有序,当新对象分配时就可以通过修改指针偏移量将其分配在第一个空闲的内存位置上,从而快速分配内存,否则只能使用空闲列表(Free List)方式分配内存,如下面要讲的标记-清除(Mark-Sweep)垃圾回收算法。

  • 缺点:开辟专门的空间存放存活对象,占用更多的内存。

2.2.2 标记-清除(Mark-Sweep)

上面的内存图示是为了理解,其实内存是线性的。

从根集合开始扫描,对存活动对象进行标记,然后重新扫描整个内存空间清除未被标记的对象,优点是不需要额外的空间,缺点是重复扫描,性能低,而且产生内存碎片。

2.2.3 标记-压缩(Mark-Compact)

一样是从从根集合开始扫描,对存活动对象进行标记,然后重新扫描整个内存空间,并往一个方向移动存活对象,虽然移动对象的消耗时间,但不产生内存碎片,可以通过 Bump-the-pointer(指针碰撞)快速分配内存。

2.2.4 标记-清除-压缩

是“标记-清除”和“标记-压缩”算法的结合,唯一的不同是要等“标记-清除”多次以后,也就是多次垃圾回收进行以后才进行移动对象(压缩),以避免每次 GC(垃圾回收)后都压缩一次,降低了移动对象的耗时。

JVM 几种经典的垃圾回收算法已经讲完,下面直接进入 HotSpot 虚拟机的讲解。

2.3 HotSpot 内存分配

2.3.1 内存分配实例

public class Earth 
{   String name;    // 单位:亿年   int age;   String size;    
   public Earth(String name, int age, String size)
          {        super();
                   this.name = name;
                   this.age = age;        
                   this.size = size;           
           }   Earth e = new Earth("地球", 46, "1.0832×10^12立方千米"); 
   }

当我们去 new 一个对象时,首先会在栈空间为对象的引用 e 分配内存,这是声明 Earth e,但由于 Earth 还是一个空对象,无法使用,不指向任何实体,接着 new Earth 会在堆开辟内存给成员变量 name、age、size,并初始化为各个数据类型的默认值,然后才是初始化为自定义的赋值,接着调用构造函数通过参数列表 (“地球”, 46, “1.0832×10^12 立方千米”) 为成员变量再赋值,最后返回对象的引用(首地址)给变量 e。

   Earth e1 = new Earth("地球", 46, "1.0832×10^12立方千米");   
   Earth e2 = new Earth("地球", 46, "1.0832×10^12立方千米");   
   Earth e3 = new Earth("地球", 46, "1.0832×10^12立方千米");

就算创建多个对象,在堆中还是一个实例,在栈中 e1、e2、e3 共同指向一个实例,而由于对象创建都是频繁而且短生命周期,故一般对象被分配在堆的新生代的 Eden 区。

而堆空间是非线程安全的,是线程共享的,为对象分配内存时就要保证原子性,防止产生脏数据,这是会消耗性能的。为此 JVM 做了优化,优先为加载完成对类在 TLAB(Thread Local Allocation,本地线程分配缓冲区)中为对象实例分配内存空间。

TLAB 是在堆中 Eden 区里开辟的空间,但却是一块线程私有区域,并不是所有对象都能在这成功分配,但在 TLAB 分配是个优选项,为了优化内存分配,还可以使用 JVM 的堆外内存存放对象实例,堆空间不是唯一 一个可以存放对象实例的地方,当一个对象作用域在方法体内,但随时间推移,一旦其引用被方法体外的成员变量引用时,就发生了逃逸;

反过来,如果作用域还是局限于方法体内,JVM 就可以为对象在栈帧里分配空间,对象分配在方法的栈帧上,随着方法创建而创建,随着方法退出而消亡,无需垃圾回收。

2.3.2 运行时常量池

我们再来看一下跟运行时常量池相关的内存分配的例子(面试常客):

public class StringDemo 
     {    
           public static void stingDemo() 
        {   // 池化的思想,把共享的数据放入字符串池中,
              以减少频繁创建销毁对象的开销           
           // 引用s1被放入栈中,字符串字面量"123"如果
              存在于字符串池中,返回其引用给s1,否则,在池中新建字符串            
           String s1 = "123";  // 池中已经存在"123",
                                  不会再创建,直接拿到引用(地址值)            
           String s2 = "123";  // 就是new对象的创建过程,不会去池查找,
                              直接在堆开辟空间存放实例对象,返回对象地址给s3                
           String s3 = new String("123");// String类本身是被final
                          修饰的,final修饰会保证s4的值不变,也就是s4=123,
                          而这个字符串字面量直接可以从池中拿到,不需要创建对象                
           String s4 = "1" + "23";       
           String str = "1";        
           // 由于被final修饰,str的值"1"不能改变,s5的值也不能变,其值是str+"23"创建对象所返回的地址(不是指对象内容       
           // 123不许变),所以这里会新建一个对象       
           String s5 = str + "23";        // 两个基本类型用==比较,比较的是两个基本类型的值是否相等       
           // 一个包装类型和一个基本类型用==比较,比较包装类型的值和基本类型的值是否相等       
           // 两个包装类型用==比较,比较两个对象返回的地址是否相等       // 两个包装类型用equals比较,比较两个对象是否相等,       
           // 包括两个对象类的类型是否相同,对象里的值是否完全相同,对象的hashcode是否相同,不比较地址,所以hashcode相同,对象不一定相等,对象相等,hashcode一定相同       
           // s1==s2:true       
           System.out.println("s1==s2:" + (s1 == s2)); // s1==s3:false       
           System.out.println("s1==s3:" + (s1 == s3)); // s1.equals(s3):true       
           // Sting类已经帮我们覆写了Object的equals方法,使得equals的比较正常,       
           // 如果没覆写,底层还是用==做的比较,我们自定义对象要用equals比较的前提是记得覆写equals方法       
           System.out.println("s1.equals(s3):" + (s1.equals(s3)));        
           // s1=s4:true       
           System.out.println("s1=s4:" + (s1 == s4));   
          }    
           public static void main(String[] args) 

         {       
           StringDemo.stingDemo();   
          }
      }
      public class TestInteger 
       {    
           public static void main(String[] args)

            {        
               int i1 = 129;        
               // java在编译的时候,会变成 Integer i2 = Integer.valueOf(129)       
               Integer i2 = 129;        
               // int和integer(无论是否new出来的)比,都为true,因为会把Integer自动拆箱为int再去比较       
               System.out.println("int i1 = 129 == Integer i2= 129 :" + (i1 == i2));       
               Integer i3 = new Integer(129);        
               // Integer与new Integer不会相等。不会经历拆箱过程,i3与i2指向是两个不同的地址,为false       
               System.out.println("Integer i2= 129 == Integer i3 = new Integer(129) :" + (i2 == i3));       
               Integer i4 = new Integer(129);   
                    
               // 两个都是new出来的,开辟不同的内存空间,都为false       
               System.out.println("Integer i3 = new Integer(129) == Integer i4 =new Integer(129) :" + (i3 == i4));       
               Integer i5 = 129;        
/*        
* Integer i2 = 129 会被编译成Integer.valueOf(129) 而valueOf的源码如下 public static        
* Integer valueOf(int i) {         * assert IntegerCache.high >= 127;        
* 如果值在-128到127之间,直接从缓存取值,不需要重新创建         
* if (i >= IntegerCache.low && i <=IntegerCache.high)         
* return IntegerCache.cache[i + (-IntegerCache.low)];         
* return new Integer(i);         * }        
*/       
// 两个都是非new出来的Integer,如果数在-128到127之间,则是true,否则为false,超过范围不会在常量池里取,会重新创建两个Integer,==比较Integer的值,即是地址,肯定false       
              System.out.println("Integer i2= 129 == Integer i5 = 129 :" + (i2 == i5));       i2 = 127;       i5 = 127;        // 在-128到127之间,从常量池取同一个引用给Integer,肯定是true       
              System.out.println("Integer i2= 127 == Integer i5 = 127 :" + (i2 == i5));   
              }
   
          }

HotSpot 内存管理里,新生代 80% 的对象生命周期较短,GC 频率高,适合采用效率较高的复制算法,经历了多次 GC 仍然存活的对象或者一些超过设定值大小的对象会分配到老年代,老年代 GC 频率较低,适合使用“标记 - 清除 - 压缩”这种综合的算法。

回收算法还有回收方式的不同,串行回收(Serial),是指就算有多个 CPU 核,某一时刻也只能有一个 CPU 核可以执行垃圾回收线程,此时用户线程会被挂起处于暂停状态,只有等回收线程执行完毕,用户线程才能继续执行,也就是会产生所谓的 Stop-the-world,JVM 短时间内卡顿不会工作。

并行回收是指某一时刻可以由多个 CPU 核同时执行各自的垃圾回收线程,不过一样会出现 Stop-the-world,而并发回收是指用户线程和垃圾回收线程交替执行,大大缩短 Stop-the-world 的停顿时间。现在大型项目动辄使用上百 G 的内存,内存越大,回收时间越久,而 Stop-the-world 的卡顿时间也会越久,目前还没有算法可以做到零停顿的。

算法的思想讲完了,下面就讲垃圾收集算法的具体实现垃圾收集器。

上面红色横线的地方就是安全点,用户线程执行时,要到达了安全点,才能暂停,让回收线程执行;当触发回收线程执行时,不会直接中断用户线程,而是设置一个标志位,让用户线程轮询。发现为中断标志时就运行到最近的安全点再将自己挂起让 CPU 执行回收线程,但如果此时用户线程处于 Waiting 或者 Blocked 状态,无法轮询标志位,就会造成回收线程长时间无法运行的情况。

为此引入了安全区,安全区就是引用关系不会发生变化的代码,在这段代码的任何地方发起 GC 都是安全的。所以当用户线程进入到安全区,恰好这时回收线程要执行就会直接中断用户线程,用户线程离开安全区时,只需检查回收线程是否已经完成,如果完成则可以离开,否则等待直到 GC 完毕。

3、HotSpot 垃圾回收器

3.1 新生代可用的垃圾回收器

Serial Coping(串行复制),Parallel Scavenge(并行复制),ParNew(并发复制)这三种回收器都是基于复制算法,复制 young eden 和 young from 中还存活的对象到 young to,或者根据设定对象的大小和 GC 次数直接晋升到 old,清空 young eden 和 young from 中的垃圾,下一次 GC 发生交换 young from 和 young to,只可使用于新生代,是在 young eden 内存空间不足以分配给对象时触发 Minor GC(新生代垃圾回收)。

3.2 老年代可用垃圾回收器

  • Serial Old (串行标记-清理-压缩)

  • Parallel Old(并行标记-压缩)

  • CMS Concurrent Mark-Sweep(并发标记清除)

3.3 垃圾收集器的组合

  • Serial Coping(串行复制)

适合客户端工作,不适合在服务器运行,针对单 CPU,小新生代,不太在乎暂停时间的应用,可通过- XX:+UseSerialGC手动指定新生代使用 Serial Coping(串行复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • ParNew(并发复制)

是 Serial Coping(串行复制)的多线程版本,在多 CPU 核情况下可以提高收集能力,但如果是单 CPU 条件下,还要来回切换任务,不一定比 Serial Coping(串行复制)收集能力强,通过- XX:+UseParNewGC手动指定新生代使用 ParNew(并发复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • Parallel Scavenge(并行复制)

跟 ParNew(并发复制)相比更注重于吞吐量而不是低延迟,如果吞吐量优先,必然会降低 GC 的频次,也就造成 GC 回收垃圾量更多、时间更长。如果低延迟优先,为了降低每次的暂停时间,就得高频的回收,这频繁的回收又会导致吞吐量的下降,所以吐吞量和低延迟是对矛盾体,适合多 CPU、高 IO 密集操作、高计算消耗的应用,通过XX:+UseParallelGC手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • Serial Old (串行标记 - 清理 - 压缩)

单线程串行回收,停顿时间长,可以使用

- XX:+PrintGCApplicationStoppedTime

查看暂停时间,适合客户端使用,不会产生内存碎片

  • Parallel Old(并行标记 - 压缩)

根据 GC 线程数划分若干区域(Region),并行做标记,重新扫描,定位到需要压缩的 Region,统计 Region 里所有存活对象的下次要移动的目的地地址,然后并行的往一端压缩,不产生内存碎片,整理后的空闲区域是连续的,通过- XX:+UseParallelOldGC手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Parallel Old(并行标记 - 压缩)收集器执行内存回收。

  • CMS Concurrent Mark-Sweep(并发标记清除)

第一阶段是初始标记,需要 Stop-the-world,这阶段标记出那些与根对象集合所连接的不可达的对象,标记完就会被暂停的应用线程;

第二阶段是并发标记,这阶段是应用线程和回收线程交替执行,把第一步标记为不可达的对象标记为垃圾对象,由于是交替进行,一开始被标记为垃圾的对象,后面应用线程可能更改对象的引用关系导致标记错误;

所以第三阶段重新标记,需要 Stop-the-world,修正上个阶段由于对象引用或者新对象创建导致的标记错误,这阶段只有回收线程执行,确保修正的正确性。

经过三个阶段的标记,第四个阶段会并发的清除无有的对象释放内存,这阶段是应用线程和回收线程交替执行,如果用户应用线程产生了新的垃圾(浮动垃圾),只能留到下次 GC 进行回收,极端情况如果产生的新的垃圾,而老年代的预留空间又不够,就会产生 Concurrent Mode Failure,这个时候只能通过后备的 Serial Old (串行标记 - 清理 - 压缩)来进行垃圾回收。

又因为 CMS 并没有用到压缩算法,回收后会产生内存碎片,为新对象分配内存无法使用 Bump-the-pointer(指针碰撞)技术实现快速内存分配,只能使用空闲列表(Free List :JVM 会维护一张可用内存地址的列表,当需要分配空间,就从列表搜索一段和对象大小一样的连续内存块用于存放要生成的对象实例)方式分配内存。

但也可以通过- XX:CMSFullGCsBeforeCompaction,用于指定经过多少次 Full GC 后对内存碎片整理压缩,由于内存碎片不是并发执行,会带来更长的停顿时间,通过- XX:+UseConcMarkSweepGC设定新生代使用 ParNew(并发复制)收集器,老年代使用 CMS Concurrent Mark-Sweep(并发标记清除)收集器执行内存回收,当出现浮动垃圾导致 Concurrent Mode Failure 或者新对象分配内存失败时,通过备用组合新生代使用 ParNew(并发复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收,适用于要求暂停时间短,追求快速响应的应用,如互联网应用。

JVM回收需要注意的点:

 

  • 在执行 Minor GC 的时候,JVM 会检查老年代中最大连续可用空间是否大于了当前新生代所有对象的总大小,如果大于,则直接执行 Minor GC;

    如果小于了,JVM 会检查是否开启了空间分配担保机制;如果开启了,则 JVM 会检查老年代中最大连续可用空间是否大于了历次晋升到老年代中的平均大小;

    如果大于则会执行 Minor GC,如果小于则执行改为执行 Full GC,如果没有开启则直接改为执行 Full GC。

  • 当老年代(Major GC)和永久代发生 GC 时,除了 CMS 外都会触发 Full GC,Full GC 就是先按新生代 GC 方式进行 Minor GC,再按照老年代的配置进行 Major GC,包含对老年代和永久代进行 GC,若 JVM 估计 Minor GC 会产生晋升失败,则会采用 Major GC 的配置进行 Full GC。

  • 如果 Minor GC 执行失败则会执行 Full GC。

  • 吞吐量:应用运行时间/总时间,暂停时间:每次 GC 造成的暂停

  • 分区分代增量式式收集器:G1(Garbage-First)收集器

传统的分代收集也提供了并发收集,但最致命的是分代收集把整个堆空间划分成固定间隔的内存块,每次收集都很容易触发 Full GC 从而扫描整个堆空间,这会拖慢应用,而且要对整个堆空间都做内存碎片整理会很麻烦。

而增量式的收集方式是一种新的收集思想,增量收集把堆空间划分成一系列的小内存块(内存块大小可配置),使用时只使用部分内存块,等这部分内存块空间不足时,再把存活对象移动到未被使用过的内存块,避免整个堆用完了再 Full GC,可以一边使用内存一边收集垃圾。

G1 收集器将整个 Java 堆区分成约 2048 大小相同的 Region 块(分新生 Region 块、幸存 Region 块、老年 Region 块),Region 块大小在 1MB 到 32MB 之间,每个对象会被分配到 Region 块里,既可以被块内对象引用也可以被块外对象引用,在判断对象是否存活时,为了避免全堆扫描或者遗漏,是通过 Remembered Set 来检查 Reference 引用的对象是否存在不同的 Region 块中的。G1 在收集垃圾时,会对各个 Region 块的回收价值和成本做排序,根据用户配置的期望停顿时间来进行回收。

G1 收集器与 CMS 收集器执行过程类似。初始标记阶段,Stop-the-World,标记 GC Roots 可直接访问到的对象,为下一个阶段并发标记时,和应用线程交替执行时,有正确可有的 Region 来分配新建对象,并发标记阶段识别上个阶段标记对象的下层对象的活跃状态,找出存活的对象,也就是标记 GC Roots 可达对象;

最终标记阶段,Stop-the-World,修正上次线程交替执行产生的变动;

清除复制阶段,Stop-the-World,这阶段并不是最终标记执行完了就一定执行,毕竟是要 Stop-the-World,为了达到准实时(可配置在 M 毫秒内最多只占用 N 毫秒的时间进行垃圾回收)会根据用户配置的 GC 时间去决定是否做清除。

还有,因为清除复制阶段使用的是复制算法,每次清理都必须保证”to space” 空间是足够的(将存活的对象复制到未使用的 Region 块),所以只有已用空间达到了(1-h)*堆大小(h 是 G1 定义的一个堆空间的百分比阈值,是个固定值)才执行清除,把存活的对象往一个方向移动到”to space” 并整理内存,不会产生内存碎片。

接着把”Eden space” “from space” 的垃圾对象清理,根据维护的优先列表,优先回收价值最大的 Region,通过五个阶段完成垃圾收集,可以通过设定 - XX:UseG1GC 在整个 Java 堆使用 G1 进行垃圾回收,G1 适合高吞吐、低延时、大堆空间的应用。

4、垃圾回收器配置和 GC 日志分析

4.1 堆典型配置:

32 位操作系统限制堆大小介于 1.5G 到 2G,64 位操作系统无限制,同时系统可用虚拟内存,可用物理内存都会限制最大堆的配置。

堆空间分配典型设置:

  • -Xms:初始堆大小

  • -Xmx:最大堆大小

  • -XX:NewSize=n:设置年轻代大小

  • -XX:NewRatio=n:设置年轻代和年老代的比值。如 n 为 2,表示年轻代与年老代比值为 1:2,年轻代占整个年轻代年老代和的 1/3

  • -XX:SurvivorRatio=n:年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。如 n 为 2,表示 Eden:Survivor=1:2,一个 Survivor 区占整个年轻代的 1/4

  • -XX:MaxPermSize=:设置持久代大小

-Xmx5120m –Xms5120m -Xmn2g -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=16m -XX:MaxTenuringThreshold=0

  • -Xmn2g:设置年轻代大小为 2G。整个 JVM 内存大小 = 年轻代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为 64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun 官方推荐配置为整个堆的 3/8。

  • -Xss128k:设置每个线程的栈大小。JDK5.0 以后每个线程堆栈大小为 1M,以前每个线程堆栈大小为 256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的在 3000~5000 间。

  • -XX:SurvivorRatio=4:设置年轻代中 Eden 区与 Survivor 区的大小比值。

  • -XX:MaxPermSize=16m:设置持久代大小为 16m。

  • -XX:MaxTenuringThreshold=0:设置年轻代最大年龄。如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在 Survivor 区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概率。

  • -XX:+DisableExplicitGC:这个将会忽略手动调用 GC 的代码使得 System.gc() 的调用就会变成一个空调用,完全不会触发任何 GC

4.2 垃圾回收器配置

1、JVM体系结构

线程共享内存

可以被所有线程共享的区域,包括堆区、方法区、运行时常量池。

1.1 堆(Heap)

大多数时候,Java 堆是 Java 虚拟机管理的内存里最大的一块,所有的对象实例和数组都要在堆上分配内存空间,Java 对象可以分为两类,一类是快速创建快速消亡的,另一类是长期使用的。所以针对这种情况大多收集器都是基于分代收集算法进行回收。

Java 的堆可以分为新生代(Young Generation)和老年代(Old Generation),而新生代(Young Generation)又可以分为 Eden Space 空间 (伊甸园区)、From Survivor 空间(From 生存区)、To Survivor 空间(To 生存区)。

Java 堆是一块共享的区域,会出现线程安全的问题,而操作共享区域就需要锁和同步,通过- Xms设置堆的最小值,堆内存越小越容易发生内存不够用的情况而触犯 Full GC(对新生代、老年代、永久代进行垃圾回收)。官方推荐新生代大小占整个堆大小的 3/8,通过- Xmx设置堆的最大值,堆内存超过此值会发抛出 OutOfMemoryError 异常:

1.2 方法区(Method Area)

方法区(Method Area)在 HotSpot 虚拟机上可以看作是永久代(Permanent Generation),对于其他虚拟机(JRockit 、J9 等)来说是不存在永久代的。方法区也是所有线程共享的区域,主要存储被虚拟机加载的类信息、常量、静态变量,堆存储对象数据,方法区存储静态信息。

方法区不像 Java 堆区那样经常发生垃圾回收,但不表示不会发生。永久代的大小跟新生代、老年代比都是很小的,通过设置- XX:MaxPermSize来指定最大内存,方法区需要的内存超过此值会抛出 OutOfMemoryError 异常。

1.3 运行时常量池(Runtime Constant Pool)

Java 通过类加载机制可以把字节码文件中的常量池表加载进运行时常量池,而我们也可使用 String 类的 intern() 方法在运行期将新的常量放入池中,运行时常量池是方法区的一部分,在 JDK1.7 的 HotSpot 中,已经把原本放在方法区的常量池移出来了。

线程私有内存

只允许被所属的线程私自访问的内存区,包括 PC 寄存器、Java 栈和本地方法栈。

1.4 栈(Java Stack)

Java Stack 描述的是 Java 方法执行时的内存模型,每个方法执行时都会创建一个栈帧(Stack Frame),栈帧包含局部变量表(存放编译期间的各种基本数据类型,对象引用等信息)、操作数栈、动态链接、方法出口等数据。

一个线程运行时会分配栈空间,每个线程的栈空间数据是相互隔离的,所以栈是私有的,堆是共享的,一个线程执行多个方法,会入栈出栈多个栈帧(多个方法),栈是先进后出的数据结构,最先入栈的栈帧,最后出栈,可以通过-Xss设置每个线程栈的大小,越小,能创建的线程数就越多,但并不是可以无限的,在一个进程里(JVM 进程)能生成的线程数最多不超过五千

1.5 本地方法栈(Native Stack)

虚拟机栈(Java Stack)为执行 Java 方法(就是字节码)服务,而本地方法栈(Native Stack)则为 Native 方法(比如用 C/C++ 编写的代码)服务,其他方面都很类似。

1.6 PC 寄存器(程序计数器)

JVM 字节码解析器通过改变 PC 寄存器的值来明确下一条需要执行的字节码指令,每个线程都会分配一个独立的 PC 寄存器。

2、JVM 垃圾回收算法

JVM 垃圾收集算法不同虚拟机的具体实现会不一样,这里先讲解几种经典的垃圾收集算法的思想,后面再以使用得最广泛的 HotSpot 虚拟机为例讲解具体的垃圾收集器算法。

2.1 引用计数法

给每个对象维护一个引用计数器,每当被引用一次就加 1,每当引用失效一次就减 1,引用计数器为 0,表明当前对象没有被任何对象引用,则可以当作垃圾回收。但是当 A 对象和 B 对象相互引用对方的时候,大家的计数器值都不为 0,而如果对象 A 和对象 B 都已经不被外部引用,就是说两个无用的对象因为相互引用而无法进行垃圾回收。这就是循环引用的缺陷,故现在 JVM 虚拟机大多不采用这种方式做垃圾回收。

2.2 根搜索算法(Tracing)

  • 复制 (Coping)

  • 标记-清除 (Mark-Sweep)

  • 标记-压缩(Mark-Compact)

  • 分代收集算法(Generational Collection)

根搜索算法从那些被称为 GC Roots 的对象(虚拟机栈中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈 JNI 引用的对象)作为起始节点,从这些节点向下搜索,搜索所形成的路径叫引用链。当一个对象到 GC Roots 的所有对象都没有一条引用链,则说明该对象不可用,所以根搜索算法又叫可达性算法,GC Roots 到该对象不可达则表明该对象不可用,表明该对象可回收。

根搜索算法有四种,其中复制算法应用在新生代。

2.2.1 复制算法

复制算法将内存划分相等的两块,当一块内存用完了,将还存活的对象移动到另一块内存,将之前使用的内存一次清理,新生代的内存空间通常都是所有代里最大的,适用复制算法,实际上垃圾回收时是把 Eden Space 和 From Survivor 上还存活的对象复制到 To Survivor,而不需要按照 1:1 的比例来划分。

通常 Eden Space:From Survivor:To Survivor = 8:1:1,如果出现状况 To Survivor 空间不足以容纳复制过来的还存活的对象,那通过分配担保机制,这些对象可直接进入老年代,然后下一次垃圾回收发生时 From Survivor 和 To Survivor 交换身份,内存直接从 To Survivor 分配,回收到 From Survivor。

  • 优点:没有标记和清除的过程,效率高,没有内存碎片,可以利用 Bump-the-pointer(指针碰撞)技术实现快速内存分配,因为已用和未用的内存各自一边,内存分布规整有序,当新对象分配时就可以通过修改指针偏移量将其分配在第一个空闲的内存位置上,从而快速分配内存,否则只能使用空闲列表(Free List)方式分配内存,如下面要讲的标记-清除(Mark-Sweep)垃圾回收算法。

  • 缺点:开辟专门的空间存放存活对象,占用更多的内存。

2.2.2 标记-清除(Mark-Sweep)

上面的内存图示是为了理解,其实内存是线性的。

从根集合开始扫描,对存活动对象进行标记,然后重新扫描整个内存空间清除未被标记的对象,优点是不需要额外的空间,缺点是重复扫描,性能低,而且产生内存碎片。

2.2.3 标记-压缩(Mark-Compact)

一样是从从根集合开始扫描,对存活动对象进行标记,然后重新扫描整个内存空间,并往一个方向移动存活对象,虽然移动对象的消耗时间,但不产生内存碎片,可以通过 Bump-the-pointer(指针碰撞)快速分配内存。

2.2.4 标记-清除-压缩

是“标记-清除”和“标记-压缩”算法的结合,唯一的不同是要等“标记-清除”多次以后,也就是多次垃圾回收进行以后才进行移动对象(压缩),以避免每次 GC(垃圾回收)后都压缩一次,降低了移动对象的耗时。

JVM 几种经典的垃圾回收算法已经讲完,下面直接进入 HotSpot 虚拟机的讲解。

2.3 HotSpot 内存分配

2.3.1 内存分配实例

public class Earth 
{   String name;    // 单位:亿年   int age;   String size;    
   public Earth(String name, int age, String size)
          {        super();
                   this.name = name;
                   this.age = age;        
                   this.size = size;           
           }   Earth e = new Earth("地球", 46, "1.0832×10^12立方千米"); 
   }

当我们去 new 一个对象时,首先会在栈空间为对象的引用 e 分配内存,这是声明 Earth e,但由于 Earth 还是一个空对象,无法使用,不指向任何实体,接着 new Earth 会在堆开辟内存给成员变量 name、age、size,并初始化为各个数据类型的默认值,然后才是初始化为自定义的赋值,接着调用构造函数通过参数列表 (“地球”, 46, “1.0832×10^12 立方千米”) 为成员变量再赋值,最后返回对象的引用(首地址)给变量 e。

   Earth e1 = new Earth("地球", 46, "1.0832×10^12立方千米");   
   Earth e2 = new Earth("地球", 46, "1.0832×10^12立方千米");   
   Earth e3 = new Earth("地球", 46, "1.0832×10^12立方千米");

就算创建多个对象,在堆中还是一个实例,在栈中 e1、e2、e3 共同指向一个实例,而由于对象创建都是频繁而且短生命周期,故一般对象被分配在堆的新生代的 Eden 区。

而堆空间是非线程安全的,是线程共享的,为对象分配内存时就要保证原子性,防止产生脏数据,这是会消耗性能的。为此 JVM 做了优化,优先为加载完成对类在 TLAB(Thread Local Allocation,本地线程分配缓冲区)中为对象实例分配内存空间。

TLAB 是在堆中 Eden 区里开辟的空间,但却是一块线程私有区域,并不是所有对象都能在这成功分配,但在 TLAB 分配是个优选项,为了优化内存分配,还可以使用 JVM 的堆外内存存放对象实例,堆空间不是唯一 一个可以存放对象实例的地方,当一个对象作用域在方法体内,但随时间推移,一旦其引用被方法体外的成员变量引用时,就发生了逃逸;

反过来,如果作用域还是局限于方法体内,JVM 就可以为对象在栈帧里分配空间,对象分配在方法的栈帧上,随着方法创建而创建,随着方法退出而消亡,无需垃圾回收。

2.3.2 运行时常量池

我们再来看一下跟运行时常量池相关的内存分配的例子(面试常客):

public class StringDemo 
     {    
           public static void stingDemo() 
        {   // 池化的思想,把共享的数据放入字符串池中,
              以减少频繁创建销毁对象的开销           
           // 引用s1被放入栈中,字符串字面量"123"如果
              存在于字符串池中,返回其引用给s1,否则,在池中新建字符串            
           String s1 = "123";  // 池中已经存在"123",
                                  不会再创建,直接拿到引用(地址值)            
           String s2 = "123";  // 就是new对象的创建过程,不会去池查找,
                              直接在堆开辟空间存放实例对象,返回对象地址给s3                
           String s3 = new String("123");// String类本身是被final
                          修饰的,final修饰会保证s4的值不变,也就是s4=123,
                          而这个字符串字面量直接可以从池中拿到,不需要创建对象                
           String s4 = "1" + "23";       
           String str = "1";        
           // 由于被final修饰,str的值"1"不能改变,s5的值也不能变,其值是str+"23"创建对象所返回的地址(不是指对象内容       
           // 123不许变),所以这里会新建一个对象       
           String s5 = str + "23";        // 两个基本类型用==比较,比较的是两个基本类型的值是否相等       
           // 一个包装类型和一个基本类型用==比较,比较包装类型的值和基本类型的值是否相等       
           // 两个包装类型用==比较,比较两个对象返回的地址是否相等       // 两个包装类型用equals比较,比较两个对象是否相等,       
           // 包括两个对象类的类型是否相同,对象里的值是否完全相同,对象的hashcode是否相同,不比较地址,所以hashcode相同,对象不一定相等,对象相等,hashcode一定相同       
           // s1==s2:true       
           System.out.println("s1==s2:" + (s1 == s2)); // s1==s3:false       
           System.out.println("s1==s3:" + (s1 == s3)); // s1.equals(s3):true       
           // Sting类已经帮我们覆写了Object的equals方法,使得equals的比较正常,       
           // 如果没覆写,底层还是用==做的比较,我们自定义对象要用equals比较的前提是记得覆写equals方法       
           System.out.println("s1.equals(s3):" + (s1.equals(s3)));        
           // s1=s4:true       
           System.out.println("s1=s4:" + (s1 == s4));   
          }    
           public static void main(String[] args) 

         {       
           StringDemo.stingDemo();   
          }
      }
      public class TestInteger 
       {    
           public static void main(String[] args)

            {        
               int i1 = 129;        
               // java在编译的时候,会变成 Integer i2 = Integer.valueOf(129)       
               Integer i2 = 129;        
               // int和integer(无论是否new出来的)比,都为true,因为会把Integer自动拆箱为int再去比较       
               System.out.println("int i1 = 129 == Integer i2= 129 :" + (i1 == i2));       
               Integer i3 = new Integer(129);        
               // Integer与new Integer不会相等。不会经历拆箱过程,i3与i2指向是两个不同的地址,为false       
               System.out.println("Integer i2= 129 == Integer i3 = new Integer(129) :" + (i2 == i3));       
               Integer i4 = new Integer(129);   
                    
               // 两个都是new出来的,开辟不同的内存空间,都为false       
               System.out.println("Integer i3 = new Integer(129) == Integer i4 =new Integer(129) :" + (i3 == i4));       
               Integer i5 = 129;        
/*        
* Integer i2 = 129 会被编译成Integer.valueOf(129) 而valueOf的源码如下 public static        
* Integer valueOf(int i) {         * assert IntegerCache.high >= 127;        
* 如果值在-128到127之间,直接从缓存取值,不需要重新创建         
* if (i >= IntegerCache.low && i <=IntegerCache.high)         
* return IntegerCache.cache[i + (-IntegerCache.low)];         
* return new Integer(i);         * }        
*/       
// 两个都是非new出来的Integer,如果数在-128到127之间,则是true,否则为false,超过范围不会在常量池里取,会重新创建两个Integer,==比较Integer的值,即是地址,肯定false       
              System.out.println("Integer i2= 129 == Integer i5 = 129 :" + (i2 == i5));       i2 = 127;       i5 = 127;        // 在-128到127之间,从常量池取同一个引用给Integer,肯定是true       
              System.out.println("Integer i2= 127 == Integer i5 = 127 :" + (i2 == i5));   
              }
   
          }

HotSpot 内存管理里,新生代 80% 的对象生命周期较短,GC 频率高,适合采用效率较高的复制算法,经历了多次 GC 仍然存活的对象或者一些超过设定值大小的对象会分配到老年代,老年代 GC 频率较低,适合使用“标记 - 清除 - 压缩”这种综合的算法。

回收算法还有回收方式的不同,串行回收(Serial),是指就算有多个 CPU 核,某一时刻也只能有一个 CPU 核可以执行垃圾回收线程,此时用户线程会被挂起处于暂停状态,只有等回收线程执行完毕,用户线程才能继续执行,也就是会产生所谓的 Stop-the-world,JVM 短时间内卡顿不会工作。

并行回收是指某一时刻可以由多个 CPU 核同时执行各自的垃圾回收线程,不过一样会出现 Stop-the-world,而并发回收是指用户线程和垃圾回收线程交替执行,大大缩短 Stop-the-world 的停顿时间。现在大型项目动辄使用上百 G 的内存,内存越大,回收时间越久,而 Stop-the-world 的卡顿时间也会越久,目前还没有算法可以做到零停顿的。

算法的思想讲完了,下面就讲垃圾收集算法的具体实现垃圾收集器。

上面红色横线的地方就是安全点,用户线程执行时,要到达了安全点,才能暂停,让回收线程执行;当触发回收线程执行时,不会直接中断用户线程,而是设置一个标志位,让用户线程轮询。发现为中断标志时就运行到最近的安全点再将自己挂起让 CPU 执行回收线程,但如果此时用户线程处于 Waiting 或者 Blocked 状态,无法轮询标志位,就会造成回收线程长时间无法运行的情况。

为此引入了安全区,安全区就是引用关系不会发生变化的代码,在这段代码的任何地方发起 GC 都是安全的。所以当用户线程进入到安全区,恰好这时回收线程要执行就会直接中断用户线程,用户线程离开安全区时,只需检查回收线程是否已经完成,如果完成则可以离开,否则等待直到 GC 完毕。

3、HotSpot 垃圾回收器

3.1 新生代可用的垃圾回收器

Serial Coping(串行复制),Parallel Scavenge(并行复制),ParNew(并发复制)这三种回收器都是基于复制算法,复制 young eden 和 young from 中还存活的对象到 young to,或者根据设定对象的大小和 GC 次数直接晋升到 old,清空 young eden 和 young from 中的垃圾,下一次 GC 发生交换 young from 和 young to,只可使用于新生代,是在 young eden 内存空间不足以分配给对象时触发 Minor GC(新生代垃圾回收)。

3.2 老年代可用垃圾回收器

  • Serial Old (串行标记-清理-压缩)

  • Parallel Old(并行标记-压缩)

  • CMS Concurrent Mark-Sweep(并发标记清除)

3.3 垃圾收集器的组合

  • Serial Coping(串行复制)

适合客户端工作,不适合在服务器运行,针对单 CPU,小新生代,不太在乎暂停时间的应用,可通过- XX:+UseSerialGC手动指定新生代使用 Serial Coping(串行复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • ParNew(并发复制)

是 Serial Coping(串行复制)的多线程版本,在多 CPU 核情况下可以提高收集能力,但如果是单 CPU 条件下,还要来回切换任务,不一定比 Serial Coping(串行复制)收集能力强,通过- XX:+UseParNewGC手动指定新生代使用 ParNew(并发复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • Parallel Scavenge(并行复制)

跟 ParNew(并发复制)相比更注重于吞吐量而不是低延迟,如果吞吐量优先,必然会降低 GC 的频次,也就造成 GC 回收垃圾量更多、时间更长。如果低延迟优先,为了降低每次的暂停时间,就得高频的回收,这频繁的回收又会导致吞吐量的下降,所以吐吞量和低延迟是对矛盾体,适合多 CPU、高 IO 密集操作、高计算消耗的应用,通过XX:+UseParallelGC手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • Serial Old (串行标记 - 清理 - 压缩)

单线程串行回收,停顿时间长,可以使用

- XX:+PrintGCApplicationStoppedTime

查看暂停时间,适合客户端使用,不会产生内存碎片

  • Parallel Old(并行标记 - 压缩)

根据 GC 线程数划分若干区域(Region),并行做标记,重新扫描,定位到需要压缩的 Region,统计 Region 里所有存活对象的下次要移动的目的地地址,然后并行的往一端压缩,不产生内存碎片,整理后的空闲区域是连续的,通过- XX:+UseParallelOldGC手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Parallel Old(并行标记 - 压缩)收集器执行内存回收。

  • CMS Concurrent Mark-Sweep(并发标记清除)

第一阶段是初始标记,需要 Stop-the-world,这阶段标记出那些与根对象集合所连接的不可达的对象,标记完就会被暂停的应用线程;

第二阶段是并发标记,这阶段是应用线程和回收线程交替执行,把第一步标记为不可达的对象标记为垃圾对象,由于是交替进行,一开始被标记为垃圾的对象,后面应用线程可能更改对象的引用关系导致标记错误;

所以第三阶段重新标记,需要 Stop-the-world,修正上个阶段由于对象引用或者新对象创建导致的标记错误,这阶段只有回收线程执行,确保修正的正确性。

经过三个阶段的标记,第四个阶段会并发的清除无有的对象释放内存,这阶段是应用线程和回收线程交替执行,如果用户应用线程产生了新的垃圾(浮动垃圾),只能留到下次 GC 进行回收,极端情况如果产生的新的垃圾,而老年代的预留空间又不够,就会产生 Concurrent Mode Failure,这个时候只能通过后备的 Serial Old (串行标记 - 清理 - 压缩)来进行垃圾回收。

又因为 CMS 并没有用到压缩算法,回收后会产生内存碎片,为新对象分配内存无法使用 Bump-the-pointer(指针碰撞)技术实现快速内存分配,只能使用空闲列表(Free List :JVM 会维护一张可用内存地址的列表,当需要分配空间,就从列表搜索一段和对象大小一样的连续内存块用于存放要生成的对象实例)方式分配内存。

但也可以通过- XX:CMSFullGCsBeforeCompaction,用于指定经过多少次 Full GC 后对内存碎片整理压缩,由于内存碎片不是并发执行,会带来更长的停顿时间,通过- XX:+UseConcMarkSweepGC设定新生代使用 ParNew(并发复制)收集器,老年代使用 CMS Concurrent Mark-Sweep(并发标记清除)收集器执行内存回收,当出现浮动垃圾导致 Concurrent Mode Failure 或者新对象分配内存失败时,通过备用组合新生代使用 ParNew(并发复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收,适用于要求暂停时间短,追求快速响应的应用,如互联网应用。

JVM回收需要注意的点:

 

  • 在执行 Minor GC 的时候,JVM 会检查老年代中最大连续可用空间是否大于了当前新生代所有对象的总大小,如果大于,则直接执行 Minor GC;

    如果小于了,JVM 会检查是否开启了空间分配担保机制;如果开启了,则 JVM 会检查老年代中最大连续可用空间是否大于了历次晋升到老年代中的平均大小;

    如果大于则会执行 Minor GC,如果小于则执行改为执行 Full GC,如果没有开启则直接改为执行 Full GC。

  • 当老年代(Major GC)和永久代发生 GC 时,除了 CMS 外都会触发 Full GC,Full GC 就是先按新生代 GC 方式进行 Minor GC,再按照老年代的配置进行 Major GC,包含对老年代和永久代进行 GC,若 JVM 估计 Minor GC 会产生晋升失败,则会采用 Major GC 的配置进行 Full GC。

  • 如果 Minor GC 执行失败则会执行 Full GC。

  • 吞吐量:应用运行时间/总时间,暂停时间:每次 GC 造成的暂停

  • 分区分代增量式式收集器:G1(Garbage-First)收集器

传统的分代收集也提供了并发收集,但最致命的是分代收集把整个堆空间划分成固定间隔的内存块,每次收集都很容易触发 Full GC 从而扫描整个堆空间,这会拖慢应用,而且要对整个堆空间都做内存碎片整理会很麻烦。

而增量式的收集方式是一种新的收集思想,增量收集把堆空间划分成一系列的小内存块(内存块大小可配置),使用时只使用部分内存块,等这部分内存块空间不足时,再把存活对象移动到未被使用过的内存块,避免整个堆用完了再 Full GC,可以一边使用内存一边收集垃圾。

G1 收集器将整个 Java 堆区分成约 2048 大小相同的 Region 块(分新生 Region 块、幸存 Region 块、老年 Region 块),Region 块大小在 1MB 到 32MB 之间,每个对象会被分配到 Region 块里,既可以被块内对象引用也可以被块外对象引用,在判断对象是否存活时,为了避免全堆扫描或者遗漏,是通过 Remembered Set 来检查 Reference 引用的对象是否存在不同的 Region 块中的。G1 在收集垃圾时,会对各个 Region 块的回收价值和成本做排序,根据用户配置的期望停顿时间来进行回收。

G1 收集器与 CMS 收集器执行过程类似。初始标记阶段,Stop-the-World,标记 GC Roots 可直接访问到的对象,为下一个阶段并发标记时,和应用线程交替执行时,有正确可有的 Region 来分配新建对象,并发标记阶段识别上个阶段标记对象的下层对象的活跃状态,找出存活的对象,也就是标记 GC Roots 可达对象;

最终标记阶段,Stop-the-World,修正上次线程交替执行产生的变动;

清除复制阶段,Stop-the-World,这阶段并不是最终标记执行完了就一定执行,毕竟是要 Stop-the-World,为了达到准实时(可配置在 M 毫秒内最多只占用 N 毫秒的时间进行垃圾回收)会根据用户配置的 GC 时间去决定是否做清除。

还有,因为清除复制阶段使用的是复制算法,每次清理都必须保证”to space” 空间是足够的(将存活的对象复制到未使用的 Region 块),所以只有已用空间达到了(1-h)*堆大小(h 是 G1 定义的一个堆空间的百分比阈值,是个固定值)才执行清除,把存活的对象往一个方向移动到”to space” 并整理内存,不会产生内存碎片。

接着把”Eden space” “from space” 的垃圾对象清理,根据维护的优先列表,优先回收价值最大的 Region,通过五个阶段完成垃圾收集,可以通过设定 - XX:UseG1GC 在整个 Java 堆使用 G1 进行垃圾回收,G1 适合高吞吐、低延时、大堆空间的应用。

4、垃圾回收器配置和 GC 日志分析

4.1 堆典型配置:

32 位操作系统限制堆大小介于 1.5G 到 2G,64 位操作系统无限制,同时系统可用虚拟内存,可用物理内存都会限制最大堆的配置。

堆空间分配典型设置:

  • -Xms:初始堆大小

  • -Xmx:最大堆大小

  • -XX:NewSize=n:设置年轻代大小

  • -XX:NewRatio=n:设置年轻代和年老代的比值。如 n 为 2,表示年轻代与年老代比值为 1:2,年轻代占整个年轻代年老代和的 1/3

  • -XX:SurvivorRatio=n:年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。如 n 为 2,表示 Eden:Survivor=1:2,一个 Survivor 区占整个年轻代的 1/4

  • -XX:MaxPermSize=:设置持久代大小

-Xmx5120m –Xms5120m -Xmn2g -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=16m -XX:MaxTenuringThreshold=0

  • -Xmn2g:设置年轻代大小为 2G。整个 JVM 内存大小 = 年轻代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为 64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun 官方推荐配置为整个堆的 3/8。

  • -Xss128k:设置每个线程的栈大小。JDK5.0 以后每个线程堆栈大小为 1M,以前每个线程堆栈大小为 256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的在 3000~5000 间。

  • -XX:SurvivorRatio=4:设置年轻代中 Eden 区与 Survivor 区的大小比值。

  • -XX:MaxPermSize=16m:设置持久代大小为 16m。

  • -XX:MaxTenuringThreshold=0:设置年轻代最大年龄。如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在 Survivor 区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概率。

  • -XX:+DisableExplicitGC:这个将会忽略手动调用 GC 的代码使得 System.gc() 的调用就会变成一个空调用,完全不会触发任何 GC

4.2 垃圾回收器配置

......

本文未完,点赞后私信小编【jvm】获得完整本文。

JVM高级特性与最佳实践 1995年的时候,Sun公司发布了Java编程语言,很多软件工程师从此喜欢上了这个编程语言。Java无疑成为当时软件开发领域的“疯子”。近几年随着互联网、移动互联网等信息化革命的到来,Java也在经历着重大的变革。今天的大多数企业都在采用Java作为核心开发语言,包括美国的阿里巴巴、亚马逊、苹果、微软、Facebook、Netflix、Twitter等。Java运行在虚拟机之上,所以也就意味着Java程序可以执行效率很高。 阅读详情

相关推荐

周志明:深入理解Java虚拟机:JVM高级特性与最佳实践

链接:https://pan.baidu.com/s/1LPrPn4gWZ6LbnqN2ZmxWzg 提取码:u07m 复制这段内容后打开百度网盘手机App,操作更方便哦

知识改变命运,ヾ(◍°∇°◍)ノ゙【求关注】 882

jvm内存模型和垃圾回收机制

jvm内存模型和垃圾回收机制一、Java虚拟机组成部分执行流程二、jvm内存模型1.本地方法栈2.程序计数器3.方法区4.栈5.堆三、jvm内存中各个区的作用与联系1.例一:1.1 内存中的栈帧结构栈帧数据结构栈帧的内存分配当前栈帧(Current Stack Frame)1.2 代码执行过程中栈的变化1.3 代码执行过程中各个区域的联系1.4 垃圾回收算法1.4.1 标记-清理算法:1.4.2 ...

qq_41534252的博客 966

深入理解Java虚拟机:JVM高级特性与最佳实践(最新第二版)

JVM高级特性与最佳实践(最新第二版)pdf 免费下载 pdf下载 公号:纯洁的明依:回复 Java虚拟机。

纯洁的明依 320

JVM虚拟机内存模型、垃圾回收算法以及类加载机制详解

从jvm内存模型到字节码文件、垃圾回收器、回收算法,以及常见的面试题等都有说到

酸萝卜别吃 1306

JVM高级特性与最佳实践 第2版:实战指南与代码剖析

Java虚拟机(JVM)是运行Java字节码的虚拟机进程,它为运行在其中的Java程序提供了必要的环境。JVM位于操作系统之上,是Java跨平台的核心保证,负责将Java程序编译成平台无关的字节码,并在各种不同平台上运行。JVM提供了多种垃圾回收器,每种都有其适用的场景和优缺点。常见的垃圾回收器包括Serial、Parallel、CMS(Concurrent Mark Sweep)、G1和ZGC(Z Garbage Collector)等。Serial收集器。

weixin_42605397的博客 1002

虚拟机器人平台CoppeliaSim(Vrep)的逆运动学教学

相信看这篇博客的人都知道CoppeliaSim是做什么的,因此这里不多赘述。CoppeliaSim从最早的Vrep到现在已经更新了N个版本,从过去Vrep3.x开始使用的我这里小小地吐槽一下这个软件。CoppeliaSim每一个版本都有更新,问题是这些更新有时候不能向前兼容,而且时不时更新地让人不知所措(对于有紧急任务的时候真的很火大啊)。 以本文所展示地逆运动学来说,CoppeliaSim在4.0.0之后又大变,推翻了过往所有的拖动模块链接之类的构建方式,转而采用直接通过代码来实现逆运动学。 简单版逆运动

qq_39243015的博客 6698

jvm内存模型及垃圾回收机制

jvm内存模型,类加载器,垃圾回收算法和垃圾回收器。

qq_14946371的博客 465

MiniMax H3本地部署与ComfyUI集成实战:从环境配置到提示词优化

文本到视频生成是AI内容创作领域的关键技术,它基于扩散模型等深度学习原理,将自然语言描述转化为连贯的动态视觉序列。这项技术的核心价值在于大幅降低了视频制作的门槛,为短视频创作、概念可视化、教育内容生成等场景提供了高效工具。在实际工程实践中,成功部署和运行此类模型,如近期备受关注的MiniMax H3,需要精准处理环境配置、显存优化和提示词工程等环节。其中,解决“CUDA error: no kernel image is available”等版本兼容性问题,以及针对“8G显存”等有限资源进行参数调优,是确

weixin_33674976的博客 478

JVM的内存模型和垃圾回收

JVM的内存模型是指JVM管理的内存区域,包括程序计数器、虚拟机栈、本地方法栈、堆和方法区。垃圾回收是JVM中的一项重要功能,用于自动管理堆内存的分配和释放。当对象没有被任何引用所引用时,垃圾回收器将标记并释放它所占用的内存。垃圾回收器使用不同的算法来识别和回收不再使用的对象

qq_63161848的博客 1311

【ue4】【新手入门】|静态网格模型碰撞设置和合并Actor(合并静态网格体)

上一期学到把构建好的房子模型变成一个静态网格体(也是一个模型)但是静态网格体是没有碰撞的 所以要给这个模型设置碰撞。

m0_63501513的博客 4420

VirtualBox虚拟机上ubuntu系统安装问题记:显示画面太小、启动就异常退出等

报错:Failed to load R0 module D:/VMMR0.r0: The path is not clean of leading double slashes: ‘D:/VMMR0.r0’ (VERR_SUPLIB_PATH_NOT_CLEAN).解决方法特别简单,就是安装VIrtualBox的时候不要安装在CDE等盘根目录下。首先VirtualBox的下载安装过程是比较简单的,此处就不赘述了。然后E盘下新建个目录,安装到这个目录下就可解决。

qq_42595610的博客 2825

JVM高级特性

jvm是自动内存管理的。

qq_51321722的博客 992

超级全的PCB LAYOUT高速信号走线指南——简化应用版

在PCB Layout中,几乎涵盖了所有的高速信号走线指南。包括了TF/SD、HDMI、LVDS、DVP、eDP、MIPI、USB2.0、USB3.0、MAC、BT.1120、PCIE高速信号等等。此版本为简化版。

bhxc809751502的博客 8548

JVM 高级特性与前沿技术

Shenandoah在Java 17/21中已经完全稳定,成为中等规模应用的优秀选择。**模块化系统(JPMS)**在LTS版本中更加稳定,为企业级应用提供可靠保障新一代垃圾收集器(ZGC、Shenandoah)在Java 17/21中性能显著提升多语言支持得益于虚拟线程等新特性,实现更好的性能和开发体验长期支持确保生产环境的稳定性和可维护性选择Java 17或Java 21作为目标版本,可以充分利用这些前沿技术的成熟实现,为现代应用开发提供强大支持。

真实的菜的博客 1136

NEU-DET 冷轧钢缺陷检测:基于 YOLOv8 的目标检测与可视化 UI 实现

冷轧钢的生产过程中,由于设备故障、工艺问题或者人为因素,钢板表面可能会出现多种缺陷,如划痕、裂纹、孔洞等,这些缺陷会严重影响钢材的质量。它比之前的版本(如 YOLOv5、YOLOv4)在速度和精度上都有显著提升,并且对硬件要求较低,适合进行工业界面和嵌入式系统的应用。这些版本相较于更大型的模型(如 YOLOv8x)具有更快的推理速度和较低的资源占用,适合工业生产线中的实时应用。通过将训练好的模型部署到工业生产线摄像头中,我们可以实现实时的缺陷检测,极大地提升生产效率和产品质量。

YOLO 736

【批处理脚本】-0.1-如何运行批处理bat文件

如何运行批处理bat文件

「汽车电子助手」的博客 1585

Java高级特性与JVM

一、java基础知识(必须会的基础) 1. 语言基础 1)关键字:static, volatile, final , transient 2)高阶语法:内部类(高频考点)、泛型。 1.1 static:属于类而非实例 static变量不会被GC回收,也就意味着有内存泄露的风险。 附:融云的static内存缓存,如果不加控制,可能会由于内存缓存太多而导致频繁的full GC。 static会将所引用...

胜天半子 511

搭建一个简单的 Django + Vue 项目

在本文中,我将为您详细介绍如何搭建一个简单的 Django + Vue 项目。Django 是一个使用 Python 编写的高级 Web 框架,而 Vue 是一个流行的 JavaScript 框架,用于构建用户界面。通过结合 Django 和 Vue,我们可以创建强大的全栈 Web 应用程序。创建 Django 项目设置 Django 后端创建 Vue 前端连接 Django 和 Vue让我们开始吧!

JfpBlockchain的博客 351

干货来袭!JVM&G1 GC+JVM高级特性和最佳实践,助你深度掌握JVM

G1 GC提出了不确定性Region,每个空闲Region不是为某个固定年代准备的,它是灵活的,需求驱动的,所以G1 GC代表了先进性。本篇主要为学习Java语言的学生、初级程序员提供GC的使用参考建议及经验,着重介绍了G1 GC。中国的软件开发行业已经有几十年了,从目前的行业发展来看,单纯的软件公司很难有发展,目前流行的云计算、物联网企业实际上是综合性IT技术的整合,者就需要有综合能力的程序员。

老男孩的架构路 591

迈瑞BS系列全自动生化分析仪LIS协议接口手册(内含HL7和ASTM)

迈瑞BS系列全自动生化分析仪LIS协议接口手册,内含HL7和ASTM)

TMS_WebGMaps_1.9.8.0_Full_Source

TMS_WebGMaps_1.9.8.0_Full_Source 全源码 delphi cbuilder

深入理解Java虚拟机:JVM高级特性与最佳实践

深入理解Java虚拟机:JVM高级特性与最佳实践      作为一位 Java 程序员,你是否也曾经想深入理解 Java 虚拟机,但是却被它的复杂和深 奥拒之门外?没关系,本书极尽化繁为简之妙,能带领你在轻松中领略 Java 虚拟机的奥秘。 对其内容详细阅读

拟声的主扬在江湖 822

文档:亲测可用解决vnc灰屏,黑屏,鼠标大黑叉情况

主要解决vnc-viewer灰屏,黑屏,鼠标大黑叉等现象,详细见文档。

性能优化之 JVM 高级特性

性能优化之 JVM 高级特性作者/分享人:码侠岛码侠岛向 Ta 提问多年大型互联网项目开发经验,涉及电商平台,互金平台,在项目中负责底层代码编写,代码审查,方案的设计,第三方服务的对接,当然还有日常的编码,喜欢尬聊,沉迷工作,无法自拔,让我们一起用代码推开世界的门在面试的后期,往往都会问性能优化的问题,譬如你优化过 JVM 吗,有没有遇到过 JVM 排查的场景,如果只能说点基本的见解,那面试官给你...

qq_42345394的博客 134

jdk-8u144-linux-x64.tar.gz.

该资源为搭建Linux系统Java环境用,资源的后缀为tar.gz格式的压缩包

jvm高级特性

jvm高级特性内存区域虚拟机栈本地方法栈java堆方法区运行时常量池直接内存类加载机制类加载器Bootstrap Class LoaderExtension Class LoaderApplication Class Loader(System Class Loader) 内存区域 Java虛拟机在执行Java程序的过程中会把它所管理的内存划分为若千个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一-直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《

weixin_45408048的博客 1110

《深入理解Java虚拟机JVM高级特性与最佳实践》

G1(Garbage-First)收集器是面向服务端应用的垃圾收集器,采用标记-整理算法,可预测的停顿时间模型是其一大优势。常见调优策略包括合理设置堆大小(-Xms, -Xmx)、新生代大小(-Xmn)、选择适合的垃圾收集器及其相关参数(如-XX:+UseG1GC)、调整GC线程数等。垃圾收集(GC)是JVM自动内存管理的核心。JDK自带命令行工具如jps、jstat、jinfo、jmap、jhat和jstack等,可用于查看JVM进程状态、内存使用情况、生成堆转储快照(Heap Dump)和线程快照等。

threemissyou的博客 286

深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)读后感

今天给大家推荐一本书,也是我在学习Java的时候,觉得对我很有帮助的一本书,这本书是周志明老师的《深入理解Java虚拟机:JVM高级特性与最佳实践》。 之前刚开始学习Java的时候,是看的《Java核心技术卷一卷二》以及《Java编程思想》这种语法类的书,对Java虚拟机底层还没有去深入了解,当时去某外企面试的时候被面试官问到一些虚拟机,垃圾回收,内存分配之类的信息,一点都不了解,只能...

1907530058的博客 2826
上一篇: 资深大牛的建议,你想知道如何学习,怎么进入阿里吗?
下一篇: 腾讯T4大神精心整理,深入理解jvm
msbjyJava
博客等级 码龄6年 5粉丝 25原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值