JVM
什么是反射
JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法,对于任意一个对象,都能够调用它的任意一个方法和属性,这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制。
线程私有区
程序计数器,虚拟机栈,本地方法栈
这些是一个线程拥有一个
线程共享区
方法区,堆
这些是一个进程拥有一个
内存中的堆与栈
- 栈是运行时的单位,而堆是存储的单位
即:栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。 - 一般来讲,对象主要都是放在堆空间的,是运行时数据区比较大的一块
- 栈空间存放 基本数据类型的局部变量,以及引用数据类型的对象的引用
堆和栈的区别
2、共享性不同
栈内存是线程私有的。
堆内存是所有线程共有的
4、空间大小
栈的空间大小远远小于堆的
1.栈内存存储的是局部变量而堆内存存储的是实体;
2.栈内存的更新速度要快于堆内存,因为局部变量的生命周期很短;
3.栈内存存放的变量生命周期一旦结束就会被释放,而堆内存存放的实体会被垃圾回收机制不定时的回收。
Java内存结构 (区别于java内存模型)
方法区和堆是所有线程共享的内存区域;而java栈、本地方法栈和程序员计数器是运行是线程私有的内存区域。
-
Java堆(Heap),是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。 有垃圾回收 有OOMError
-
方法区(Method Area),方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。 有垃圾回收 有OOMError
-
程序计数器(Program Counter Register),程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是存储下一条指令的地址。取出就是执行,执行完再取下一条。任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法的JVM指令地址;或者,如果实在执行native方法,则是未指定值(undefined)没有垃圾回收 它是唯一一个在java虚拟机规范中没有规定任何OOM情况的区域。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
唯一一个既没有GC 也没有OOM OutOfMemoryError:
-
JVM(虚拟机栈)栈(JVM Stacks),与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。 每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
-
本地方法栈(Native Method Stacks),本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用 本地方法是由C语言编写
程序计数器为什么是私有
程序计数器主要有下面两个作用:
字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
虚拟机栈为什么是私有
虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
字符串常量池 引用常量池 Stingtable
类加载器和类加载过程
类加载过程
加载->链接-》初始化
加载 -》验证-》准备—>解析-》初始化
类加载子系统作用
- 类加载子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识;
- ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定
- 加载的类信息存放于一块成为方法区的内存空间。除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
类加载器ClassLoader角色
加载
- 通过一个类的全限定名获取定义此类的二进制字节流;
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据;
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
链接
初始化
- 初始化阶段就是执行类构造器方法clinit()的过程。
- 此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。
类加载器分类
- JVM支持两种类型的加载器,分别为引导类加载器(BootStrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)
- 从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。
- 无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有三个,如下所示:
BootStrap是由C和C++实现的,下面都是Java代码实现的
自定义类与核心类库的加载器
- 对于用户自定义类来说:使用系统类加载器AppClassLoader进行加载
- java核心类库都是使用引导类加载器BootStrapClassLoader加载的
虚拟机自带的加载器
-
①启动类加载器(引导类加载器,BootStrap ClassLoader)
-
②拓展类加载器(Extension ClassLoader)
-
③应用程序类加载器(也称为系统类加载器,SystemClassLoader / AppClassLoader)
用户自定义类加载器
为什么
- 隔离加载类
- 修改类加载的方式
- 拓展加载源
- 防止源码泄漏
双亲委派机制
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将她的class文件加载到内存生成的class对象。而且加载某个类的class文件时,java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派 模式
双亲委派机制工作原理
什么是双亲委派机制?(这个图很重要)
当某个类加载器需要加载某个.class
文件时,它首先把这个任务委托给他的上级类加载器,递归这个操作,如果上级的类加载器没有加载,自己才会去加载这个类。
双亲委派机制的缺点
按照双亲委派机制的工作流程, 应用类访问系统类自然是没有问题, 但是系统类访问应用类就会出现问题。 比如在系统类中提供了一个接口, 该接口需要在应用类中得以实现, 该接口还绑定一个工厂方法, 用于创建该接口的实例, 而接口和工厂方法都在启动类加载器中。 这时, 就会出现该工厂方法无法创建由应用类加载器加载的应用实例。
三次破坏双亲委派机制 好像没问过
第一次 JDK1.2以前:双亲委派机制是JDK1.2的时候出现,类加载器的概念第一个版本已经存在;
第二次模型自身缺陷造成:如果基础类型又要调用会用户的代码怎么办(线程上下文加载器)
由于用户对程序动态性的追求导致
类加载过程
加载,链接,初始化
加载 验证 准备 解析 初始化
java对象的创建过程
-
判断对象对应的类是否加载、链接、初始化、
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程(双亲委派模式下)。
-
为对象分配内存:首先计算对象占用空间大小,接着在堆中划分一块内存分给对象,如果实例成员变量是引用变量,仅仅分配引用变量空间即可,即4个字节大小。
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的
指针碰撞:
-
如果内存规整一指针碰撞
-
如果内存不规整:
- 虚拟机需要维护一个列表
- 空闲列表分配
-
处理并发安全问题:分配空间在堆里面分配的,而堆是共享的,多个线程就会出现安全问题。通常来讲,虚拟机采用两种方式来保证线程安全:
- 采用 CAS+失败重试 保证更新的原子性 CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- 每个线程预先分配一块TLAB 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
-
初始化分配到的空间(默认初始化):所有属性设置默认值,这样就可以保证对象实例字段在不赋值时可以直接使用 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
-
设置对象的对象头 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
-
执行init方法进行初始化( 显示初始化): 一般来说,执行 new 指令之后会接着执行
<init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
java对象的生命周期
java类的生命周期就是指一个class文件从加载到卸载的全过程。
创建,使用,回收
其中创建包括 加载-》验证-》准备-》解析-》 初始化
使用:就是通过句柄池直接访问,或者是直接指针
PC计数器
使用PC寄存器存储字节码指令地址有什么用呢?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行
为什么使用PC寄存器记录当前线程的执行地址呢?
JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令
PC寄存器为什么会设定为线程私有
我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停滴做任务切换,这样必然会导致经常中断或恢复,如何保证分毫无差呢? 为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器, 这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?
每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
常见的六种OOM异常和错误
java.lang.StackOverflowError 同时,栈内存也决定方法调用的深度,栈内存过小则会导致方法调用的深度较小,如递归调用的次数较少。
java.lang.OutOfMemoryError: Java heap space 当堆内存(Heap Space)没有足够空间存放新创建的对象时
Permgen space Metaspace 通常是因为加载的 class 数目太多或体积太大。
Java虚拟机栈
虚拟机栈是什么
- java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。 每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应这个一次次的java方法调用。它是线程私有的。 Java虚拟机栈用于管理Java方法的调用
- 生命周期和线程是一致的
- 作用:主管java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。
- 局部变量:相对于成员变量(或属性)
- 基本数据变量: 相对于引用类型变量(类,数组,接口)
栈的特点
- 栈是一种快速有效的分配存储方式,访问速度仅次于PC寄存器(程序计数器)
- JVM直接对java栈的操作只有两个
- 每个方法执行,伴随着进栈(入栈,压栈)
- 执行结束后的出栈工作
- 对于栈来说不存在垃圾回收问题
栈中可能出现的异常
java虚拟机规范允许Java栈的大小是动态的或者是固定不变的
-
如果采用固定大小的Java虚拟机栈,那每一个线程的java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量,java虚拟机将会抛出一个 StackOverFlowError异常
-
如果java虚拟机栈可以动态拓展,并且在尝试拓展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个 OutOfMemoryError异常。
栈的存储结构和运行原理
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame) 的格式存在
- 在这个线程上正在执行的每个方法都对应各自的一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
- JVM直接对java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进后出/后进先出的和原则。
- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Frame)
- 不同线程中所包含的栈帧是不允许相互引用的,即不可能在另一个栈帧中引用另外一个线程的栈帧
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
栈帧的内部结构
每个栈帧中存储着:
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)(用于保存计算过程的中间结果)
- 动态链接(Dynamic Linking)(或执行运行时常量池的方法引用)
- 方法返回地址(Return Adress)(或在方法退出后都返回到该方法被调用的位置定义)
- 一些附加信息
虚拟机栈的5道面试题
1.举例栈溢出的情况?(StackOverflowError)
- 递归调用等,通过-Xss设置栈的大小;
2.调整栈的大小,就能保证不出现溢出么?
- 不能 如递归无限次数肯定会溢出,调整栈大小只能保证溢出的时间晚一些
3.分配的栈内存越大越好么?
- 不是 会挤占其他线程的空间
4.垃圾回收是否会涉及到虚拟机栈?
- 不会
内存区块 | Error | GC |
---|---|---|
程序计数器 | ❌ | ❌ |
本地方法栈 | ✅ | ❌ |
jvm虚拟机栈 | ✅ | ❌ |
堆 | ✅ | ✅ |
方法区 | ✅ | ✅ |
5.方法中定义的局部变量是否线程安全?
- 要具体情况具体分析
本地方法栈
- Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用
堆
https://juejin.cn/post/6844904173671202829#heading-3
堆的细分内存结构 JDK7和8区别
JDK 7以前,内存逻辑上可以分为: 新生区+养老区+永久区
- Young Generation Space:又被分为Eden区和Survior区 Young/New
- Tenure generation Space: Old/Tenure
- Permanent Space: Perm
JDK 8以后: 新生区+养老区+元空间
- Young Generation Space:又被分为Eden区和Survior区 Young/New
- Tenure generation Space: Old/Tenure
- Meta Space: Meta
年轻代与老年代
存储在JVM中的java对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 另外一类对象时生命周期非常长,在某些情况下还能与JVM的生命周期保持一致
Java堆区进一步细分可以分为年轻代(YoungGen)和老年代(OldGen)
其中年轻代可以分为Eden空间、Survivor0空间和Survivor1空间(有时也叫frmo区,to区)
配置新生代与老年代在堆结构的占比
- 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
- 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
在hotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1(测试的时候是6:1:1),开发人员可以通过选项 -XX:SurvivorRatio 调整空间比例,如-XX:SurvivorRatio=8
几乎所有的Java对象都是在Eden区被new出来的
绝大部分的Java对象都销毁在新生代了(IBM公司的专门研究表明,新生代80%的对象都是“朝生夕死”的)
可以使用选项-Xmn设置新生代最大内存大小(这个参数一般使用默认值就好了)
说一下堆内存中对象的分配的基本策略(很重要)
图解对象分配过程
- new的对象先放伊甸园区。此区有大小限制。
- 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
- 然后将伊甸园中的剩余对象移动到幸存者0区。
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
- 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
- 啥时候能去养老区呢?可以设置次数。默认是15次。·可以设置参数:-XX:MaxTenuringThreshold=进行设置。
- 在养老区,相对悠闲。当老年区内存不足时,再次触发GC:Major GC,进行养老区的内存清理。
- 若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常。
总结
针对幸存者s0,s1区:复制之后有交换,谁空谁是to
关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不再永久区/元空间收集。
对象分配的特殊情况
常用调优工具
- JDK命令行
- Eclipse:Memory Analyzer Tool
- Jconsole
- VisualVM
- Jprofiler
- Java Flight Recorder
- GCViewer
- GC Easy
如何判断对象可以被回收?
判断对象是否存活一般有两种方式:
- 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。
- 可达性分析(Reachability Analysis):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,不可达对 0
Minor GC、Major GC、Full GC(看不懂)
JVM在进行GC时,并非每次都针对上面三个内存区域(新生代、老年代、方法区)一起回收的,大部分时候回收都是指新生代。
针对hotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)
- 部分收集(Partial GC):不是完整收集整个Java堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
- 目前,只有CMS GC会有单独收集老年代的行为
- 注意,很多时候Major GC 会和 Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
- 目前,只有G1 GC会有这种行为(因为G1分了很多的region)
- 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集
触发机制
-
年轻代GC(Minor GC)触发机制
- 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC.(每次Minor GC会清理年轻代的内存,Survivor是被动GC,不会主动GC)
- 因为Java对象大多是朝生夕死,所以Minor GC 非常频繁,一般回收速度也比较快,这一定义既清晰又利于理解。
- Minor GC 会引发STW(Stop the World),暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行。
-
老年代GC(Major GC/Full GC)触发机制
- 指发生在老年代的GC,对象从老年代消失时,Major GC 或者 Full GC 发生了
- 出现了Major GC,经常会伴随至少一次的Minor GC(不是绝对的,在Parallel Scavenge 收集器的收集策略里就有直接进行Major GC的策略选择过程)
- 也就是老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC
- Major GC速度一般会比Minor GC慢10倍以上,STW时间更长
- 如果Major GC后,内存还不足,就报OOM了
-
Full GC触发机制
- 触发Full GC执行的情况有以下五种
- ①调用System.gc()时,系统建议执行Full GC,但是不必然执行
- ②老年代空间不足
- ③方法区空间不足
- ④通过Minor GC后进入老年代的平均大小 大于老年代的可用内存
- ⑤由Eden区,Survivor S0(from)区向S1(to)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
- 说明:Full GC 是开发或调优中尽量要避免的,这样暂停时间会短一些
- 触发Full GC执行的情况有以下五种
堆空间分代思想
为什么要把Java堆分代?不分代就不能正常工作了么
- 经研究,不同对象的生命周期不同。70%-99%的对象都是临时对象。
- 新生代:有Eden、Survivor构成(s0,s1 又称为from to),to总为空
- 老年代:存放新生代中经历多次依然存活的对象
- 其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描,而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
内存分配策略(堆部分总结)
- 如果对象在Eden出生并经过第一次Minor GC后依然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,把那个将对象年龄设为1.对象在Survivor区中每熬过一次MinorGC,年龄就增加一岁,当它的年龄增加到一定程度(默认15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中
- 对象晋升老年代的年龄阈值,可以通过选项 -XX:MaxTenuringThreshold来设置
- 针对不同年龄段的对象分配原则如下:
- 优先分配到Eden
- 大对象直接分配到老年代
- 尽量避免程序中出现过多的大对象(大对象导致GC,然后导致STW)
- 长期存活的对象分配到老年代
- 动态对象年龄判断
- 如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入到老年代。无需等到MaxTenuringThreshold中要求的年龄
- 空间分配担保
- -XX: HandlePromotionFailure
为对象分配内存:TLAB(线程私有缓存区域-所以此时堆就不一定是线程共享)(先不看)
为什么有TLAB(Thread Local Allocation Buffer)
-
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
-
由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
-
为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度(因为Eden区分配内存是需要加锁的 )TLAB就应运而生
至于为什么内存分配需要加锁,因为线程可能竞争一块内存空间,而那一块内存空间只能分配给一个对象
什么是TLAB
- 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内
- 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略**
- 所有OpenJDK衍生出来的JVM都提供了TLAB的设计
说明
- 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
- 在程序中,开发人员可以通过选项“-XX:UseTLAB“ 设置是够开启TLAB空间
- 默认情况下,TLAB空间的内存非常小,仅占有整个EDen空间的1%,当然我们可以通过选项 ”-XX:TLABWasteTargetPercent“ 设置TLAB空间所占用Eden空间的百分比大小
- 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配了内存
堆是分配对象的唯一选择么(不是)
在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
此外,前面提到的基于OpenJDK深度定制的TaoBaoVM,其中创新的GCIH(GCinvisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
- 如何将堆上的对象分配到栈,需要使用逃逸分析手段。
- 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
- 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
- 逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
- 如何快速的判断是否发生了逃逸分析,就看new的对象实体是否有可能在方法外被调用
堆区本章小节
注意:MinorGC主要是针对年轻代,MajorGC针对老年代,FullGC针对堆区,方法区。
方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
堆、栈、方法区的交互关系
运行时数据区结构图
堆、栈、方法区的交互关系
方法区的理解
永久代是方法区在JDK1.7之前的一种实现,此时永久代使用的还是Java虚拟机的内存,元空间是1.8以后方法区的一种实现,此时用的是使用的是本地内存
《Java虚拟机规范》中明确说明:‘尽管所有的方法区在逻辑上属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。’但对于HotSpotJVM而言,方法区还有一个别名叫做Non-heap(非堆),目的就是要和堆分开。
所以,方法区可以看作是一块独立于Java堆的内存空间。
-
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域
-
方法区在JVM启动时就会被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的
-
方法区的大小,跟堆空间一样,可以选择固定大小或者可拓展
-
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space 或者 java.lang,OutOfMemoryError :Metaspace,比如:
- 加载大量的第三方jar包;
- Tomcat部署的工程过多;
- 大量动态生成反射类;
-
关闭JVM就会释放这个区域的内存
例,使用jvisualvm查看加载类的个数 -
在jdk7及以前,习惯上把方法区称为永久代。jdk8开始,使用元空间取代了永久代
-
本质上,方法区和永久代并不等价。仅是对hotSpot而言的。《java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEA JRockit/IBM J9中不存在永久代的概念
- 现在看来,当年使用永久代,不是好的idea。导致Java程序更容易OOM(超过-XX:MaxPermSize上限)
方法区在jdk7及jdk8的落地实现
- 在jdk8中,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替
- 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不再虚拟机设置的内存中,而是使用本地内存
- 永久代、元空间并不只是名字变了。内部结构也调整了。
- 根据《Java虚拟机规范》得规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常.
方法区的内部结构
《深入理解Java虚拟机》书中对方法区存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。(也不要死记,后面随着JDK的更新,也有些变化,静态变量,运行时常量池中的StringTable,和字符串常量池都放在了了堆中)
类型信息:类,接口,枚举,注解
域信息(成员变量):域名称,域类型,域修饰符
运行时常量池
常量池
几种在常量池内存储的数据类型包括:
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
小结
常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型、字面量等信息。
方法区的演进细节这里说的静态变量指的是变量名
- 首先明确:只有HotSpot才有永久代。 BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虛拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。
- Hotspot中 方法区的变化:
- jdk1.6及之前:有永久代(permanent generation) ,静态变量存放在 永久代上
- jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
- jdk1.8及之后: 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆
- 元空间只是方法区的一种实现,方法区是一种规范
运行时常量池,常量池,字符串常量池
永久代为什么要被元空间替换
- 随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类.的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间( Metaspace )。
- 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
- 这项改动是很有必要的,原因有:
- 1)为永久代设置空间大小是很难确定的。(分配小) 在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。
"Exception in thread' dubbo client x.x connector’java.lang.OutOfMemoryError: PermGenspace"
而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。 - 2)对永久代进行调优是很困难的。(FullGC很浪费时间,尽量少出现FullGC,所以使用本地内存更好一些)
- 1)为永久代设置空间大小是很难确定的。(分配小) 在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。
StringTable 为什么要调整
jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full GC 是老年代的空间不足、永久代不足时才会触发。这就导致了StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存.
方法区的垃圾回收
要不要垃圾回收
java虚拟机规范没有明说,可以回收,也可以不回收
如果要回收,方法区的垃圾收集主要回收两部分内容:
常量池中废奔的常量和不再使用的类型,回收类型就是费力不讨好
判断
只要常量池中的常量没有被任何地方引用,就可以被回收。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虛拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。
- 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及oSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
总结
对象实例化
创建对象的方式
- new
- 最常见的方式
- 变形1 : Xxx的静态方法
- 变形2 : XxBuilder/XxoxFactory的静态方法
Class的newInstance():反射的方式,只能调用空参的构造器,权限必须是public,已经过时- Constructor的newInstance(Xxx):反射的方式,可以调用空参、带参的构造器,权限没有要求
- 使用clone() :不调用任何构造器,当前类需要实现Cloneable接口,实现clone()
- 使用反序列化:从文件中、从网络中获取一个对象的二进制流
- 第三方库Objenesis
创建对象的步骤(重要,上面又放了一遍)
-
判断对象对应的类是否加载、链接、初始化、
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程(双亲委派模式下)。
-
为对象分配内存:首先计算对象占用空间大小,接着在堆中划分一块内存分给对象,如果实例成员变量是引用变量,仅仅分配引用变量空间即可,即4个字节大小。
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的
指针碰撞:
-
如果内存规整一指针碰撞
-
如果内存不规整:
- 虚拟机需要维护一个列表
- 空闲列表分配
-
处理并发安全问题:分配空间在堆里面分配的,而堆是共享的,多个线程就会出现安全问题
- 采用 CAS+失败重试 保证更新的原子性 CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- 每个线程预先分配一块TLAB 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
-
初始化分配到的空间(默认初始化):所有属性设置默认值,这样就可以保证对象实例字段在不赋值时可以直接使用 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
-
设置对象的对象头 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
-
执行init方法进行初始化( 显示初始化): 一般来说,执行 new 指令之后会接着执行
<init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
测试对象实例化的过程
① 加载类元信息 - ② 为对象分配内存 - ③ 处理并发问题 - ④ 属性的默认初始化(零值初始化)
⑤ 设置对象头的信息 - ⑥ 属性的显式初始化、代码块中初始化、构造器中初始化
给对象的属性赋值的操作:
① 属性的默认初始化 - ② 显式初始化 / ③ 代码块中初始化(谁先写执行谁) - ④ 构造器中初始化
对象的内存布局
运行时元数据就是markword。
美团:对象头中有哪些数据;
对象头(Header)
包含两部分
-
运行时元数据
- 哈希值( HashCode )
- GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
-
类型指针:指向类元数据的InstanceKlass,确定该对象所属的类型
说明:如果是数组,还需记录数组的长度
实例数据(Instance Data)
说明:它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段) 规则:
- 相同宽度的字段总被分配在一起
- 父类中定义的变量会出现在子类之前
- 如果CompactFields参数为true(默认为true),子类的窄变量可能插入到父类变量的空隙
对象填充(Padding)
不是必须的,也没特别含义,仅仅起到占位符作用
/**
* 测试对象实例化的过程
* ① 加载类元信息 - ② 为对象分配内存 - ③ 处理并发问题 - ④ 属性的默认初始化(零值初始化)
* - ⑤ 设置对象头的信息 - ⑥ 属性的显式初始化、代码块中初始化、构造器中初始化
*
* 给对象的属性赋值的操作:
* ① 属性的默认初始化 - ② 显式初始化 / ③ 代码块中初始化 - ④ 构造器中初始化
*
*/
public class Customer{
int id = 1001;
String name;
Account acct;
{
name = "匿名客户";
}
public Customer(){
acct = new Account();
}
}
class Account{
}
public class CustomerTest {
public static void main(String[] args) {
Customer cust = new Customer();
}
}
对象的访问定位
JVM是如何通过栈帧中的对象引|用访问到其内部的对象实例的呢?-> 定位,通过栈上reference访问
对象访问的主要方式有两种
-
句柄访问:句柄访问就是说栈的局部变量表中,记录的对象的引用,然后在堆空间中开辟了一块空间,也就是句柄池
-
直接指针(HotSpot采用)直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据
句柄访问优势:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改;
直接指针优势:访问一次就可以得到对象实例数据,速度更快
直接内存(Direct Memory)(了解)
I/O和NIO(New IO / Non - Blocking IO)
- 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域
- 直接内存是Java堆外的、直接向系统申请的内存区间
/**
* IO NIO (New IO / Non-Blocking IO)
* byte[] / char[] Buffer
* Stream Channel
*
* 查看直接内存的占用与释放
*/
public class BufferTest {
private static final int BUFFER = 1024 * 1024 * 1024;//1GB
public static void main(String[] args){
//直接分配本地内存空间
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
System.out.println("直接内存分配完毕,请求指示!");
Scanner scanner = new Scanner(System.in);
scanner.next();
System.out.println("直接内存开始释放!");
byteBuffer = null;
System.gc();
scanner.next();
}
}
-
来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
-
通常,访问直接内存的速度会优于Java堆。即读写性能高
- 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
- Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
-
也可能导致OutOfMemoryError异常:OutOfMemoryError: Direct buffer memory
-
由于直接内存在Java堆外,因此它的大小不会直接受限于一Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。
-
缺点
- 分配回收成本较高
- 不受JVM内存回收管理
-
直接内存大小可以通过MaxDirectMemorySize设置
-
如果不指定,默认与堆的最大值一Xmx参数值一致
简单理解: java process memory = java heap + native memory
执行引擎
执行引擎的任务就是把字节码文件解释为对应平台上的本地机器指令才可以(相当于翻译者)
String基本特性
/**
JDK1.8
*/
private final char value[];
/**
JDK1.8
*/
private final byte[] value;
更换底层动机:
字符串类的当前实现将字符存储在字符数组中,每个字符使用两个字节(16位)。从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,而且大多数字符串对象只包含拉丁文,二大部分包含拉丁文的字符串储存的时候只需要一个字节即可,所以有一半的储存空间用不到。所以改成了byte数组加一个标志位,如果是ISO/Latin编码,还是只用一个字节,如果是其他字符集还是用两个字节。
字符串拼接操作(视频P122)
String Table
下面代码的注释 记得看一下 很重要
字符串拼接
@Test
public void test2(){
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";
//如果拼接符号的前后出现了变量,则相当于在堆空间new String(),具体内容为拼接的结果
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;
System.out.println(s3 == s4);//true
System.out.println(s3 == s5);//false
System.out.println(s3 == s6);//false
System.out.println(s5 == s6);//false
System.out.println(s5 == s7);//false
System.out.println(s6 == s7);//false
//intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,返回常量池中javaEEhadoop的地址;
//如果字符串常量池中不存在javaEEhadoop,在常量池中加载一分javaEEhadoop,并返回对象的地址
String s8 = s6.intern();
System.out.println(s3 == s8);//true
}
@Test
public void test3(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
/*
如下的s1 + s2 的执行细节:(变量s是我临时定义的)
① StringBuilder s = new StringBuilder();
② s.append("a")
③ s.append("b")
④ s.toString() --> 约等于 new String("ab")
补充:在jdk5.0之后使用的是StringBuilder,
在jdk5.0之前使用的是StringBuffer
*/
String s4 = s1 + s2;//
System.out.println(s3 == s4);//false
}
/*
1. 字符串拼接操作不一定使用的是StringBuilder!
如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
2. 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。
*/
@Test
public void test4(){
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;//因为是常量
System.out.println(s3 == s4);//true
}
//练习:
@Test
public void test5(){
String s1 = "javaEEhadoop";
String s2 = "javaEE";
String s3 = s2 + "hadoop";
System.out.println(s1 == s3);//false
final String s4 = "javaEE";//s4:常量
String s5 = s4 + "hadoop";
System.out.println(s1 == s5);//true
}
拼接操作与append的效率对比
append效率要比字符串拼接高很多
/*
体会执行效率:通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!
详情:① StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象
使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
② 使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。
改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:
StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]
*/
@Test
public void test6(){
long start = System.currentTimeMillis();
// method1(100000);//4014
method2(100000);//7
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
}
public void method1(int highLevel){
String src = "";
for(int i = 0;i < highLevel;i++){
src = src + "a";//每次循环都会创建一个StringBuilder、String
}
// System.out.println(src);
}
public void method2(int highLevel){
//只需要创建一个StringBuilder
StringBuilder src = new StringBuilder();
for (int i = 0; i < highLevel; i++) {
src.append("a");
}
// System.out.println(src);
}
intern()的使用
如果不是用双引号声明的String对象,可以使用String提供的intern方法: intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
- 比如: String myInfo = new String(“I love u”).intern();
也就是说,如果在任意字符串上调用String. intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下 列表达式的值必定是true: (“a” + “b” + “c”).intern()== “abc”;
通俗点讲,Interned String就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)。
如何保证变量s指向的是字符串常量池中的数据呢?
new String(“ab”)会创建几个对象,new String(“a”)+new String(“b”)呢
/**
* new String("ab")会创建几个对象?通过看字节码是两个
* 一个对象是:new 关键字在堆空间创建的
* 另一个是:字符串常量池中的对象"ab"。字节码指令:ldc
*/
/**
* new String("a") + new String("b")会创建几个对象?
* 会发生拼接字符串
* 对象1:new StringBuilder()
* 对象2:new String("a") 这个是堆空间的
* 对象3:常量池中的"a"
* 对象4:new String("b")
* 对象5:常量池中的"b"
* 深入剖析StringBuilder的toString()方法
* 对象6: new String("ab")
* 强调一下,toString()的调用,在字符串常量池中,没有生成"ab"(一个弹幕大佬的解释:
* StringBuilder的toString方法中,返回的new String()传参为char[] ,所以字符串常量池中并没有ab。这里面写的稍微有些问题,不应该写成new String("ab"))
*/
public class StringNewTest {
public static void main(String[] args) {
// String str = new String("ab");
String str = new String("a") + new String("b");
}
}
-
new String(“ab”)会创建几个对象?看字节码,就知道是两个。
- 一个对象是:new关键字在堆空间创建的
- 另一个对象是:字符串常量池中的对象"ab"。 字节码指令:ldc
-
new String(“a”) + new String(“b”)呢?6个对象
- 对象1:new StringBuilder()
- 对象2: new String(“a”)
- 对象3: 常量池中的"a"
- 对象4: new String(“b”)
- 对象5: 常量池中的"b"
-
深入剖析: StringBuilder的toString():
- 对象6 :new String(“ab”)
- 强调一下,toString()的调用,在字符串常量池中,没有生成"ab" 这是因为传入String构造器的是value数组,不是String对象
关于String.intern()的面试题
public static void test5(){
/**
* 会创建两个对象,一个是堆空间的,一个是字符串常量池的,返回的是堆空间的
*/
String s = new String("1");
s.intern();//调用这个方法之前字符串常量池中已经存在1,所以这个方法没有多大作用
String s2 = "1";//这个时候用的是刚刚放的1,所以下面比较的时候,一个是堆空间的,一个是指向常量池的
System.out.println(s == s2);//Jdk1.6 false jdk1.8 false
String s3 = new String("1") + new String("1");//s3变量记录的地址为: new String("11")
//执行完上一行代码以后,字符串常量池中,是否存在“11”,不存在!!!
s3.intern();//字符串常量池中生成“11”。如何理解:jdk6:创建了一个新的对象”11“,也就有了新地址。
// jdk8:此时常量池中并没有创建“11”,而是创建了一个指向堆空间的new String("11")的地址
String s4 = "11";//使用的是上一行代码执行时,常量池中生成的“11”的地址
System.out.println(s3 == s4);//Jdk1.6 false jdk1.8 true
// System.out.println(System.identityHashCode(s3));
// System.out.println(System.identityHashCode(s4));
// System.out.println(System.identityHashCode(s));
// System.out.println(System.identityHashCode(s2));
/**
* 弹幕的一个解释:
* 看字节码s的new String("1")是先从常量池中加载一个“1”,然后再执行init初始化在堆中创建对象,
* 所以在jdk6/7中虽然常量池位置不同,但是常量池中的“1”最早就创建好了 s3在直接在堆中创建,常量池中并没有创建"11";
* 执行intern()方法后,由于jdk6由于常量池在永久代,所以要在永久代单独创建一份“11”,
* 而在jdk7中常量池本身就在堆中,常量池(StringTable)直接记录S3在堆中生成的对象地址
*/
}
拓展
public class StringIntern1 {
public static void main(String[] args) {
//StringIntern.java中练习的拓展:
String s3 = new String("1") + new String("1");//new String("11")
//执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!!
String s4 = "11";//在字符串常量池中生成对象"11" 也就有了新的地址
String s5 = s3.intern();
System.out.println(s3 == s4);//false
System.out.println(s5 == s4);//true
}
}
总结String的intern()的使用
- jdk1.6中,将这个字符串对象尝试放入串池。
- ➢如果字符串常量池中有,则并不会放入。返回已有的串池中的对象的地址
- ➢如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址
- Jdk1.7起,将这个字符串对象尝试放入串池。
- ➢如果字符串常量池中有,则并不会放入。返回已有的串池中的对象的地址
- ➢如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址
练习
练习1
public class StringExer1 {
public static void main(String[] args) {
//String x = "ab";
String s = new String("a") + new String("b");//new String("ab")
//在上一行代码执行完以后,字符串常量池中并没有"ab"
String s2 = s.intern();//jdk6中:在串池中创建一个字符串"ab"
//jdk8中:串池中没有创建字符串"ab",而是创建一个引用,指向new String("ab"),将此引用返回
System.out.println(s2 == "ab");//jdk6:true jdk8:true
System.out.println(s == "ab");//jdk6:false jdk8:true
}
}
jdk6
jdk7/8
练习2
public class StringExer2 {
public static void main(String[] args) {
String s1 = new String("ab");//执行完以后,会在字符串常量池中会生成"ab"
// String s1 = new String("a") + new String("b");执行完以后,不会在字符串常量池中会生成"ab"
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2); //false
}
}
intern()效率测试
大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用 intern()方法,就会明显降低内存的大小。
垃圾回收
什么是垃圾:
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空 间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
为什么需要GC
- 释放没有用的对象。
- 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM 将整理出的内存分配给新的对象。
- 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。
Java垃圾回收机制
- 垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。
- 其中,Java堆是垃圾收集器的工作重点。
- 从次数上讲:
- 频繁收集Young区
- 较少收集old区
- 基本不动Perm区(元空间)
垃圾回收相关算法
垃圾标记阶段:对象存活判断
- 在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
- 那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
- 判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
标记阶段:法1_引用计数法 (java没有采用)
-
引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
-
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
-
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
-
缺点:
-
➢它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
-
➢每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
-
➢引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一 条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。
-
标记阶段:法2_可达性分析算法
也叫根搜索算法或追踪性垃圾收集
- 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高 效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
- 相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing GarbageCollection)。
- 所谓"GC Roots"根集合就是一组必须活跃的引用。
- 基本思路:
- ➢可达性分析算法是以根对象集合(GCRoots)为起始点,从引用关系向下搜索,搜索过程所走过的路径成为“引用链”,如果某个对象到GCRoots间没有任何引用链相连,则这个对象就是不可达。
- ➢使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- ➢如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
- ➢在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
哪些可以作为GC Roots
在Java语言中,GC Roots包括以下几类元素:
- 虚拟机栈中引用的对象
- ➢比如:各个线程被调用的方法中使用到的参数、局部变量等。
- 本地方法栈内JNI(通常说的本地方法)引用的对象
- 方法区中类静态属性引用的对象
- ➢比如:Java类的引用类型静态变量
- 方法区中常量引用的对象
- ➢比如:字符串常量池(string Table)里的引用
- 所有被同步锁synchronized持有的对象
- Java虚拟机内部的引用。
- ➢基本数据类型对应的Class对象,一些常驻的异常对象(如: NullPointerException、OutOfMemoryError) ,系统类加载器。
- 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
- 除了这些固定的GCRoots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(Partial GC)。
- ➢如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针 对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一.并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。
- 小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root
注意
- 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在 一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
- 这点也是导致GC进行时必须“StopTheWorld"的一个重要原因。
- ➢即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
清除阶段:法1_标记-清除算法
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存.
目前在JVM中比较常见的三种垃圾收集算法是标记一清除算法( Mark一Sweep)、复制算法(Copying)、标记一压缩算法(Mark一Compact)
执行过程:
当堆中的有效内存空间(available memory) 被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。
- 标记: Collector 从引用根节点开始遍历,标记所有被引用的对象(或者说标记的就是可达对象)。一般是在对象的Header中记录为可达对象。
- 清除: Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
缺点
- ➢效率不算高(需要遍历了两次)
- ➢在进行GC的时候,需要停止整个应用程序,导致用户体验差
- ➢这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表
注意:何为清除?
- 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲 的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就把原来东西覆盖掉。
清除阶段:法2_复制算法(这个算法没有标记阶段)
背景:
为了解决标记一清除算法在垃圾收集效率方面的缺陷(),M.L.Minsky于1963年发表了著名的论文,“ 使用双存储区的Li sp语言垃圾收集器CALISP Garbage Collector Algorithm Using SerialSecondary Storage )”。M.L. Minsky在该论文中描述的算法被人们称为复制(Copying)算法,它也被M. L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。
核心思想:
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
堆中S0和S1使用的就是复制算法
优点:
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现“碎片”问题。
缺点:
- 此算法的缺点也是很明显的,就是需要两倍的内存空间。
- 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系(栈中变量的引用需要改变),不管是内存占用或者时间开销也不小。
复制算法需要复制的存活对象数量并不会太大,或者说非常低才行。
应用场景:
在新生代,对常规应用的垃圾回收,一次通常可以回收70一 99%的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代(suvivor区很多对象都是朝生夕死,所以就不适合老年代)。
清除阶段:法3_标记-整理(Mark-Compact)算法
背景:
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
标记一清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。标记一压缩(Mark一Compact) 算法由此诞生。
1970年前后,G. L. Steele 、C. J. Chene和D.S. Wise 等研究者发布标记一压缩算法。在许多现代的垃圾收集器中,人们都使用了标记一压缩算法或其改进版本。
执行过程:
- 第一阶段和标记一清除算法一样,从根节点开始标记所有被引用对象.
- 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
- 之后,清理边界外所有的空间。
-
标记一压缩算法的最终效果等同于标记一清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记一清除一压缩(Mark一 Sweep一Compact)算法。
-
二者的本质差异在于标记一清除算法是一种非移动式的回收算法,标记一压.缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
-
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
指针碰撞(Bump the Pointer )
如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer) 。
优点
- 消除了标记一清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只 需要持有一个内存的起始地址即可。
- 消除了复制算法当中,内存减半的高额代价。
缺点
- 从效率.上来说,标记一整理算法要低于复制算法。
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。· 移动过程中,需要全程暂停用户应用程序。即: STW(STOP THE WORLD)
小结
- 效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。
- 而为了尽量兼顾上面提到的三个指标,标记一整理算法相对来说更平滑一些,但是效率.上不尽如人意,它比复制算法多了一个标记的阶段,比标记一清除多了一个整理内存的阶段。
Mark-Sweep | Mark-Compact | Copying | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍大小(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
分代收集算法
难道就没有一种最优的算法么?
没有最好的算法,只有更合适的算法
前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。
分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率==。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接, 这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如: String对象, 由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
目前几乎所有的GC都是采用分代收集(Generational Collecting) 算法执行垃圾回收的。在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。
- 年轻代(Young Gen)(老年代:年轻代=2:1)
- 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。
- 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。·(Eden : s0 : s1=8:1:1)
- 老年代(Tenured Gen)
- 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁。
- 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记一清除或者是标记一清除与标记一整理的混合实现。
- ➢Mark阶段的开销与存活对象的数量成正比。
- ➢Sweep阶段的开销与所管理区域的大小成正相关。
- ➢Compact阶 段的开销与存活对象的数据成正比。
以HotSpot中的CMS回收器(针对老年代的回收器)为例,CMS是基于Mark一 Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark一Compact算法的Serial 0ld回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial 0ld执行Full GC以达到对老年代内存的整理。
分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。
增量收集算法、分区算法(感觉不是太重要)
增量收集算法
上述现有的算法,在垃圾回收过程中,应用软件将处于一种stop the World的状态。在Stop the World状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting) 算法的诞生。
基本思想
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
总的来说,增量收集算法的基础仍是传统的标记一清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
缺点:
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。衡量GC算法的指标:延迟和吞吐量
分区算法
一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。
每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。
写在最后
注意,这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。
程序的并行与并发
并发(Concurrent)
- 在操作系统中,是指一个时间段中有几个程序都处于己启动运行到运行完毕之间,且这几个程序都是在同一个处理器_上运行。
- 并发不是真正意义上的“同时进行”,只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。
并行(Parallel)
- 当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)。
- 其实决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以 并行。
- 适合科学计算,后台处理等弱交互场景
二者对比
- 并发,指的是多个事情,在同一时间段内同时发生了。
- 并行,指的是多个事情,在同一时间点上同时发生了。
- 并发的多个任务之间是互相抢占资源的。
- 并行的多个任务之间是不互相抢占资源的。
- 只有在多CPU或者一个CPU多核的情况中,才会发生并行。否则,看似同时发生的事情,其实都是并发执行的。
垃圾回收的并发与并行
并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:
- 并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
- 如ParNew、 Parallel Scavenge、 Parallel 0ld;
- 串行(Serial)
- 相较于并行的概念,单线程执行。
- 如果内存不够,则程序暂停,启动JVM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。
- 并发(Concurrent) :指用户线程与垃圾收集线程同时执行(这里指的是一段时间内同时执行,但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。
- ➢用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;
- ➢如: CMS、G1
java四种引用
强引用(StrongReference)
以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
软引用(SoftReference)
如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。这样就可以保证使用缓存同时,不会耗尽内存。
弱引用(WeakReference)
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
软引用和弱引用都可以用来保存哪些可有可无的缓存数据。内存不足时,缓存数据会被回收,不会导致内存溢出,当内存充足的时候,又可以存在相当长的时间,起到加速系统的作用。
虚引用(PhantomReference)
"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
为一个对象设置引用关联的唯一目的在于跟踪垃圾回收的过程。
虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。
特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
垃圾回收器
GC的分类
- 垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。
- 由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本。
- 从不同角度分析垃圾收集器,可以将GC分为不同的类型。
按线程数(这里值得是垃圾回收的线程)分,可以分为串行垃圾回收器和并行垃圾回收器
-
串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
➢在诸如单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场 合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的Client模式下的JVM中
➢在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器。
-
和串行回收相反,并行收集可以运用多个CPU同时执行垃圾回收**(可以理解为多个垃圾回收线程同时执行)**,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“ Stop一the一world”机制。
按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器
-
并发式垃圾回收器与应用程序(一个时间段内同时执行,同时执行,不一定是并行),以尽可能减少应用程序的停顿时间。
-
独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。
按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器 -
压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。
- 再分配对象空间使用: 指针碰撞
-
非压缩式的垃圾回收器不进行这步操作。
- 再分配对象空间使用: 空闲列表
按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器
评估GC的性能指标(黄色比较重要)
- 吞吐量:运行用户代码的时间占总运行时间的比例
- (总运行时间:程序的运行时间十内存回收的时间)
- 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
- 内存占用: Java堆区所占的内存大小
- 这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。
- 这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用 多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。
- 简单来说,主要抓住两点:
- 吞吐量
- 暂停时间
吞吐量
- 吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/ (运行用户代码时间+垃圾收集时间)
- ➢比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%
- 这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。
- 吞吐量优先,意味着在单位时间内,STW的时间最短: 0.2 + 0.2 = 0.4
暂停时间
- “暂停时间”是指一个时间段内应用程序线程暂停,让GC线程执行的状态
- ➢例如,GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的。
- 暂停时间优先,意味着尽可能让单次STW的时间最短: 0.1 + 0.1 + 0.1 + 0.1+0.1=0.5
- 高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。
- 低暂停时间(低延迟)较好因为从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一一个交互式应用程序。
- 不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。
- ➢因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收。
- ➢相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩诚和导致程序吞吐量的下降。
分代收集理论中假说之一:跨代引用学说
假说1 —弱分代假说:绝大多数对象都是朝生夕死
假说2----强分代学说:熬过越多次垃圾回收过程的对象就越难以消亡。
假说3----跨代引用学说:跨代引用相对于同代引用来说只是仅仅少数。
根据前面两个假说可以逻辑推理出得出隐含推论:存在相互引用关系 的两个对象倾向于同时生存或者同时消亡。比如某个年轻对 的对象存在 跨代引用,由于老年代对象 难以消亡,这个引用 会使得这个新生代对象同样得以存活,这样在老年代增长之后,晋升到老年代中,这时候跨代引用随即被消除。所以没必要为了少量的跨代 引用去扫描 整个老年代。只需要在新生代建立一个全局的数据结构,这个结构称为记忆集。
记忆集和卡表
在分代收集理论的时候,为了解决个对象被不同区域引用的问题(分代引用问题),垃圾回收器在新生代里面建立了记忆集。
记忆集是一种记录从非收集区域指向收集区域的指针集合的抽象数据结构,一般有三种实现方式:
- 子长精度:精确到一个机器子长
- 对象精度:每个记录精确到一个对象,这个对象的字段含有跨代指针
- 卡精度(也就是卡表):每个记录精确到一块内存区域,这个区域内有对象含有跨代指针。
所以卡表是记忆集的一种实现,卡表跟记忆集的关系类似 hashmap跟map的关系;
卡表的实现
最简单的形式可能只是一个字节数组,CARD_TABLE,字节数组的每一个元素都对应他标识的内存区域的一块特定大小的内存块,这个内存块就是卡页,
一个卡页可能有多个对象,只有有一个或者多个对象的字段存在着跨代指针,那这个对应卡表的数组的元素的值标识为1,这个元素就成为变脏。在垃圾回收的时候,只要筛选出变脏的元素,就可以知道哪些内存块包含跨代指针,把它们加入到GCRoots中一起扫描。
一个对象被不同区域引用的问题(分代引用问题)
一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?
在其他的分代收集器,也存在这样的问题( 而G1更突出)
回收新生代也不得不同时扫描老年代?
这样的话会降低MinorGC的效率;
解决方法:
➢无论G1还是其他分代收集器,JVM都是使用RememberedSet来避免全局扫描:
➢每个Region都有 一个对应的Remembered Set;
➢每次Reference类 型数据写操作时,都会产生一个Write Barrier暂 时中断操作; .
➢然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region (其他收集器:检查老年代对象是否引用了新生代对象) ;
➢如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;
➢当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。
三个屏障 :
下面的维护卡表的写屏障、低延迟收集器里面的读屏障、和解决并发乱序执行问题的内存屏障不同。
写屏障 是为了解决卡表维护问题就是卡表什么时候变脏
卡表元素变脏:当分代区域对象 引种了本区域对象的时候,对应的卡表元素就应该变脏。
Hotspot里面是通过写屏障维护卡表,可以看做虚拟机层面对“引用数据类型字段赋值”这个动作的AOP切面。或者说 这个数据类字段的赋值操作的前后都在 写屏障 的覆盖范畴内。赋值前的 写屏障 叫做写前屏障, 赋值后的 写屏障 叫做写后屏障。直到G1出现之前,其他收集器都是只出现了写后屏障。
但是卡表在高并发场景下面临着“伪共享”问题,可以通过先检查卡表标记,只有当卡表元素还没有被标记过才把他变脏。
疑问:卡表是0为脏,还是1为脏。
并发的可达性分析 增量更新和原始快照 三色标记
并发标记阶段,用户可以直接操作对象,可能使得引用关系发生修改,使所有指向本来活的对象的应用全部消失,此时这些对象就会被判别为死亡,但实际还是存活的,这个问题就比较严重。
G1跟CMS中记忆集的区别见后面G1和CMS的对比
不同的垃圾回收器概述
七款经典的垃圾收集器
- 串行回收器:Serial、 Serial Old
- 并行回收器:ParNew、 Parallel Scavenge、Parallel Old
- 并发回收器:CMS、G1
七款经典的垃圾收集器与垃圾分代之间的关系
- 新生代收集器: Serial、 ParNeW、Parallel Scavenge;
- 老年代收集器: Serial 0ld、 Parallel 0ld、 CMS;
- 整堆收集器: G1(读作G First);
垃圾收集器的组合关系
- 两个收集器间有连线,表明它们可以搭配使用: Serial/Serial 01d、Serial/CMS、 ParNew/Serial 01d、ParNew/CMS、 Parallel Scavenge/Serial 01d、Parallel Scavenge/Parallel 0ld、G1;
- 其中Serial 0ld作为CMS 出现"Concurrent Mode Failure"失败的后备预案。 3.(红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、 ParNew+Serial 01d这两个组合声明为废弃(JEP 173) ,并在JDK 9中完全取消了这些组合的支持(JEP214),即:移除。
- (绿色虚线)JDK 14中:弃用Parallel Scavenge和Serial0ld GC组合(JEP366 )
- (青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363)
- 为什么要有很多收集器个不够吗? 因为Java的使用场景很多, 移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。
- 虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器。
Serial回收器:串行回收
- Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的选择。
- Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器。
- Serial收集器采用复制算法、串行回收和"Stop一 the一World"机制的方式执行内存回收。
- 除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial 0ld收集器。 Serial 0ld收集器同样也采用了串行回收 和"Stop the World"机制,只不过内存回收算法使用的是标记一整理算法。
- ➢Serial 0ld是运行在Client模式下默认的老年代的垃圾回收器
- ➢Serial 0ld在Server模式下主要有两个用途:①与新生代的ParallelScavenge配合使用; ②作为老年代CMS收集器的后备垃圾收集方案
- 这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World )。
优势
- 简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Seria1收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
- ➢运行在Client模式下的虛拟机是个不错的选择。
- 在用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB), 可以在较短时间内完成垃圾收集(几十ms至一百多ms) ,只要不频繁发生,使用串行回收器是可以接受的。
总结
- 这种垃圾收集器大家了解,现在已经不用串行的了。而且在限定单核cpu才可以用。现在都不是单核的了。
- 对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在Javaweb应用程序中是不会采用串行垃圾收集器的。
ParNew回收器:并行回收
- 如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本。
- ➢Par是Paralle1的缩写,New: 只能处理的是新生代
- ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、"Stop一 the一World"机制。
- ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器。
- 对于新生代,回收次数频繁,使用并行方式高效。
- 对于老年代,回收次数少,使用串行方式节省资源。(CPU并行 需要切换线程,串行可以省去切换线程的资源)
- 由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比Serial收集器更高效?
- ➢ParNew 收集器运行在多CPU的环境下,由于可以充分利用多CPU、 多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
- ➢但是在单个CPU的环境下,ParNew收 集器不比Serial收集器更高 效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
- 因为除Serial外,目前只有ParNew GC能与CMS收集器配合工作
Parallel回收器:吞吐量优先
- HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外, Parallel Scavenge收集器同样也采用了复制算法、并行回收和"Stop the World"机制。
- 那么Parallel收集器的出现是否多此一举?
- ➢和ParNew收集器不同,Parallel Scavenge收集 器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
- ➢自适应调节策略(根据当前运行情况动态调整内存分配情况)也是Parallel Scavenge 与ParNew一个重要区别。
- 高吞吐量则可以高效率地利用CPU 时间,尽快完成程序的运算任务|,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
- Parallel收集器在JDK1.6时提供了用于执行老年代垃圾收集的 Parallel 0ld收集器,用来代替老年代的Serial 0ld收集器。
- Parallel 0ld收集器采用了标记一压缩算法,但同样也是基于并行回收和”Stop一the一World"机制。
- 在程序吞吐量优先的应用场景中,Parallel 收集器和Parallel 0ld收集器的组合,在Server(服务器端)模式下的内存回收性能很不错
- 在Java8中,默认是此垃圾收集器
CMS回收器:低延迟(就是低暂停时间)
- 在JDK1.5时期, HotSpot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器: CMS (Concurrent 一Mark 一 Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
- CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。
- ➢目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
- CMS的垃圾收集算法采用标记一清除算法,并且也 会" stop一the一world"
- 不幸的是,CMS 作为老年代的收集器,却无法与JDK 1.4.0 中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK 1. 5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。
- 在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC。
CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。
- 初始标记(Initial一Mark) 阶段:在这个阶段中,程序中所有的工作线程都将会因为. “Stop一the一World"机制而出现短暂的暂停,这个阶段的主要任务**仅仅只是标记出GCRoots能直接关联到的对象。**一旦标记完成之后就会恢复之前被暂停的所有应用.线程。由于直接关联对象比较小,所以这里的速度非常快。
- 并发标记(Concurrent一Mark)阶段:从GC Roots的直接关联对象开始遍历整个对 象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
- 重新标记(Remark) 阶段:修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
- 并发清除( Concurrent一Sweep)阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
CMS收集器的垃圾收集算法采用的是标记一清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。 那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer) 技术,而只能够选择空闲列表(Free List) 执行内存分配。
有人会觉得既然Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact呢?
答案其实很简答,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact更适合“Stop the World”这种场景”下使用
CMS的优点: .
- 并发收集
- 低延迟
CMS的弊端:
(1): CMS收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure" 失败而导致另一次完全Full GC的产生。在并发标记和并发清除阶段,由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,但是这一部分垃圾对象是在标记过程结束以后产生的,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
(2)同样是因为垃圾回收阶段用户线程还需要持续运行,就需要预留足够内存空间提供给用户线程使用,这个时候会面临另外一次风险,如果 预留内存运行空间无法满足程序分配新对象,就会出现一次concurrent mode failure,并发失败,这个时候虚拟机就会冻结用户线程运行,启动后备预案,采用serial old。
(3)因为CMS采用的是标记-清除算法,会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。
对于问题3:解决办法是:CMS提供了一个开关参数,用于CMS不得不进行FULLGC的时候,开启内存碎片的合并整理过程,这个时候内存整理必须移动存活对象,在ZGC出现之前 ,是不能无法并发。此时停顿时间会变成。
小结:
请记住以下口令:
如果你想要最小化地使用内存和并行开销,请选Serial GC;
如果你想要最大化应用程序的吞吐量,请选Parallel GC;
如果你想要最小化GC的中断或停顿时间,请选CMS GC + parNew。
JDK 后续版本中CMS的变化
- JDK9新特性: CMS被标记为Deprecate了(JEP291)
- 如果对JDK 9及以上版本的HotSpot虚拟机使用参数一XX:+UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃。
- JDK14新特性: 删除CMS垃圾回收器(JEP363)
- 移除了CMS垃圾收集器,如果在JDK14中使用一XX: +UseConcMarkSweepGC的话,JVM不会报错,只是给出一个warning信息,但是不会exit。JVM会自动回退以默认GC方式启动JVM
G1回收器:区域化分代式,是一个分区算法
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.
官方给G1设定目标是在延迟可控情况下获得尽可能高的吞吐量。
为什么名字叫做Garbage First (G1)呢?
- 因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region) (物理上 不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。
- G1 GC有计划地避免在整个Java 堆中进行全区域的垃圾收集。G1跟踪各个Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
- 由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先(Garbage First) 。
- G1 (Garbage一First) 是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。
G1 收集器的运作大致分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
优势
与其他GC收集器相比,G1使用了全新的分区算法,其特点如下所示:
-
并行与并发
- ➢并行性: G1在回收期间,可以有多个Gc线程同时工作,有效利用多核计算能力。此时用户线程STW(只要是并行,一定会STW)
- ➢并发性: G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
-
分代收集
➢从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构,上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
➢将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
➢和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;
-
空间整合
- ➢CMS: “标记一清除”算法、内存碎片、若干次GC后进行一次碎片整理
- ➢G1将内存划分为一个个的region。 内存的回收是以region作为基本单位的.Region之间是复制算法,但整体上实际可看作是标记一压缩(Mark一Compact)算法,两种算法都可以避免内存碎片。 这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。
-
可预测的停顿时间模型(即:软实时soft real一time) 这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
- ➢由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
- ➢G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以 及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1 收集器在有限的时间内可以获取尽可能高的收集效率。
- ➢相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要.好很多。
使用场景
面向服务端应用,针对具有大内存、多处理器的机器。
最主要的应用时需要低GC延迟,并具有大堆的应用程序提供解决方案
缺点
- 相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint) 还是程序运行时的额外执行负载(overload) 都要比CMS要高。
- 从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用,上则发挥其优势。平衡点在6一8GB之间。
分区region,化整为零
使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB, 2MB, 4MB, 8MB, 1 6MB, 32MB。可以通过一 XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。
虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region (不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续。
-
一个region 有可能属于Eden, Survivor 或者0ld/Tenured 内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,s表示属于Survivor内存区域,0表示属于0ld内存区域。图中空白的表示未使用的内存空间。
-
G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1. 5个region,就放到H。
-
设置H的原因:
对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。
G1回收器垃圾回收过程
G1 GC的垃圾回收过程主要包括如下三个环节:
- 年轻代GC (Young GC )
- 老年代并发标记过程( Concurrent Marking)
- 混合回收(Mixed GC )(包括YGC+老年代GC)
- (如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)
-
顺时针, young gc 一> young gc + concurrent mark 一> Mixed GC顺序,进行垃圾回收。
-
应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程; G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。
-
当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。
-
标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起 被回收的。
-
举个例子:一个web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31 个小时整个堆的使用率会达到45号,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。
G1回收过程详解
并发标记过程
-
初始标记阶段(需要STW):仅仅标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。并且修改TAMS(Top at Mark start:作用是把 region中的一部分空间划分出来用于并发回收当中的新对象分配,并发回收时新对象分配的对象地址都必须要在这两个指针以上,每个region里面都有这么两个指针)指针的值
-
并发标记(Concurrent Marking): 从GCRoots开始递归扫描整个堆里面的对象图,找到要回收的对象,并且G1要处理snapshot一at一the一beginning (SATB)(原始快照)记录下的在并发时有引用变动的对象。
-
最终标记(Final Marking,STW):短暂STW,处理并发标记阶段结束后仍然遗留下来的最后那少量的SATB记录。
-
筛选回收(需要STW):更新region里面的统计数据,对各个region的回收价值和成本进行排序,按照用户的要求制定回收计划,选择多个region构成回收集,然后回收
可以看出,除了并发标记,其他都需要STW,所以G1并不是纯粹的追求低延迟,设定目标是在延迟可控的情况下获得尽可能高的吞吐量,
4. 下面是根据尚硅谷整理的Full GC
G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop一 The一World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc, 这种情况可以通过增大内存解决。
导致G1Full GC的原因可能有两个:
- 1.Evacuation的时候没有足够的to一 space来存放晋升的对象;
- 2.并发处理过程完成之前空间耗尽。
补充
优化建议
- 年轻代大小
- ➢避免使用一Xmn或一XX:NewRatio等相关选项显式设置年轻代大小➢固定年轻代的大小会覆盖暂停时间目标
- 暂停时间目标不要太过严苛
- G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
- 评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。
G1跟CMS垃圾回收期的对比
过程:CMS的初始标记和重新标记需要STW,并发标记和并发清楚不需要,对于G1,只有并发标记不需要清楚阶段,初始标记、最终标记,筛选回收需要STW,从这个角度看,G1的重点并不是一味的追求低延迟。
内存占用:虽然他们都使用卡表处理跨代指针,但是G1的卡表实现更复杂,对于G1,每个region里面都有一份卡表,导致G1的记忆集和其他内存消耗可能占整个堆容量的20%;
对于CMS,他只需要一份卡表,而且是只需要处理老年代对新生代的引用,翻不过来不需要。
从卡表结构:G1的卡表结构更为复杂,G1的卡表结构是双向的,不仅需要记录我指向谁,还需要记录谁指向我。
执行负载:他们都使用写屏障,CMS使用写后屏障来更新 维护卡表,G1不仅使用写后屏障维护卡表,为了实现原始快照搜索算法(STAB),还需要使用写前屏障跟踪并发时的指针变化情况。相比增量更新算法,原始快照搜索可以减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点 。
由于G1对写屏障的复杂操作比CMS消耗更多的资源,所以CMS的写屏障是直接的同步操作,G1就不得不采用类似消队列的结构,把写前屏障和写后屏障要做的事情放到消息队列里面,然后再异步处理。
总结:小内存情况下,CMS的表现大概率比G1好,而大内存上大概率是G1好。
根据JVM的书,增量更新算法和原始快照算法是在并发标记阶段
垃圾回收器总结
截止JDK 1.8,一共有7款不同的垃圾收集器。每一款不同的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。
不同厂商、不同版本的虚拟机实现差别很大。HotSpot 虚拟机在JDK7/8后所有收集器及组合(连线),如下图:(这个图更新到了14)
- 1.两个收集器间有连线,表明它们可以搭配使用: Serial/Serial 0ld、Serial /CMS、ParNew/Serial 0ld、ParNew/CMS、 Parallel Scavenge/Serial 01d、Parallel Scavenge/Parallel 0ld、G1;
- 2.其中Serial 0ld作 为CMS出现"Concurrent Mode Failure"失败 的后备预案。
- 3.(红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、 ParNew+Serial 0ld这两个组合声明为Deprecated (JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214),即:移除。
- 4.(绿色虚线)JDK 14中:弃用ParallelScavenge 和Serial0ld GC组合 (JEP 366)
- 5.(青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363 ) GC发展阶段: Serial => Parallel (并行) => CMS (并发) => G1 => ZGC
低延迟垃圾回收器(没看懂)
几乎整个过程全部都是并发的,只有初始标记和最终标记这些阶段有短暂停顿,而且时间是固定的,和堆的容量、堆中对象数量没有关系。
Open JDK12的Shenandoah GC
- 现在G1回收器已成为默认回收器好几年了。
- 我们还看到了引入了两个新的收集器: ZGC ( JDK11出现)和Shenandoah(Open JDK12) 。
- ➢主打特点:低停顿时间
Open JDK12 的Shenandoah GC:低停顿时间的GC (实验性)
- Shenandoah,无疑是众多GC中最孤独的一个。是第一款不由Oracle公司团队领导开发的HotSpot垃圾收集器。不可避免的受到官方的排挤。比如号称OpenJDK和OracleJDK没有区别的Oracle公司仍拒绝在OracleJDK12中支持Shenandoah。
- Shenandoah垃圾回收器最初由RedHat进行的一项垃 圾收集器研究项目PauselessGC的实现,旨在针对JVM上的内存回收实现低停顿的需求。在2014年贡献给OpenJDK。
- Red Hat研发Shenandoah团队对外宣称,Shenandoah垃 圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为200MB还是200GB,99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内。不过实际使用性能将取决于实际工作堆的大小和工作负载。
- 这是RedHat在2016年发表的论文数据,测试内容是使用Es对200GB的维基百科数据进行索引。从结果看:
- 停顿时间比其他几款收集器确实有了质的飞跃,但也未实现最大停顿时间控制在十毫秒以内的目标。
- 而吞吐量方面出现了明显的下降,总运行时间是所有测试收集器里最长的。
- Shenandoah GC的弱项:高运行负担下的吞吐量下降。
- Shenandoah GC的强项:低延迟时间。
革命性的ZGC
官网链接
ZGC与Shenandoah目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。
《深入理解Java虚拟机》一书中这样定义ZGC: ZGC收集器是一款基于Region内存布局的,(暂时) 不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记一压缩算法的,以低延迟为首要目标的一款垃圾收集器。
ZGC的工作过程可以分为4个阶段:并发标记一并发预备重分配一并发重分配一并发重映射等。
ZGC几乎在所有地方并发执行的,除了初始标记的是sTW的。所以停顿时间.几乎就耗费在初始标记上,这部分的实际时间是非常少的。