JVM内存模型
以Sun HotSpot虚拟机为例。Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
其中方法区和堆是所有线程共享的,栈,本地方法栈和程序虚拟机则为线程私有的(编译时确定所需内存大小)。
1. 程序计数器(为了线程切换可以恢复到正确执行位置)
对线程正在运行的点位进行缓冲记录,以便在获取 CPU 时间片时能够快速恢复。
如果线程执行的是个java方法,那么计数器记录虚拟机字节码指令的地址。如果为native【底层方法】,那么计数器为空。
这块内存区域是虚拟机规范中唯一没有OutOfMemoryError
的区域,不需要进行 GC
。
2. 虚拟机栈(基本类型的变量+对象句柄)
在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦完成相应的调用,则出栈。所有的栈帧都出栈后,线程也就结束了。
每个栈帧
都有**局部变量表,操作栈,动态链接,返回地址**
等信息。每一个方法被调用的过程就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。
所有的字节码指令,其实都会抽象成对栈的入栈出栈操作。执行引擎只需要傻瓜式的按顺序执行,就可以保证它的正确性。
两种error:
- 线程请求的栈深度大于虚拟机允许的栈深度,将抛出
StackOverflowError
。 - 虚拟机栈空间可以动态扩展,当动态扩展是无法申请到足够的空间时,抛出
OutOfMemory
异常。
3. 本地方法栈
虚拟机栈执行的是Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的native方法服务。
在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。会抛出StackOverflowError+OutOfMemory
栈中主要存放一些基本类型的变量(int, short, long, byte, float, double, boolean, char)和对象句柄。
栈有一个很重要的特殊性,就是存在栈中的数据可以共享。
4. 堆 (new创建的对象和数组)
堆是java虚拟机管理内存最大
的一块内存区域,因为堆存放的对象是线程共享的,所以多线程的时候也需要同步机制。它的目的是存放对象实例。同时它也是GC所管理的主要区域
- 能按扩展实现(通过设置 -Xmx和-Xms),如果堆中没有内存内存完成实例分配,而且堆无法扩展将报OOM错误(
OutOfMemoryError
)
为了保证对象的内存分配过程中的线程安全性,HotSpot虚拟机提供了一种叫做TLAB(Thread Local Allocation Buffer)的技术。堆是线程共享的内存区域”这句话并不完全正确,因为TLAB是堆内存的一部分,他在读取上确实是线程共享的,但是在内存分配上,是线程独享的。
堆Heap内存用于存放由new创建的对象和数组。
Java中的对象都是在堆上分配的吗?
Java中的对象不一定是在堆上分配的,因为JVM通过逃逸分析,能够分析出一个新对象的使用范围,并以此确定是否要将这个对象分配到堆上。
即时编译判断对象是否逃逸的依据:一种是对象是否被存入堆中(静态字段或者堆中对象的实例字段),另一种就是对象是否被传入未知代码。
并不是所有的对象和数组,都是在堆上进行分配的,由于即时编译的存在,如果JVM发现某些对象没有逃逸出方法,就很有可能被优化成在栈上分配。
那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?
这和两个方面有关:对象的类型和在 Java 类中存在的位置。
Java 的对象可以分为基本数据类型和普通对象。
对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。
对于基本数据类型来说(byte、short、int、long、float、double、char)。 我们上面提到,每个线程拥有一个虚拟机栈。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。 注意,像 int[] 数组这样的内容,是在堆上分配的。数组并不是基本数据类型。
5. 元空间
用于存储已被虚拟机加载的类信息、常量、静态变量,如static修饰的变量加载类的时候就被加载到方法区中。在JDK1.8中,使用元空间
代替永久代来实现方法区(元空间并不在虚拟机中,而是使用本地内存),元数据空间并不在虚拟机中,而是使用本地内存(不需要进行 GC)
。但会出现OutOfMemoryError
为什么有 Metaspace 区域?它有什么问题?
使用非堆可以使用操作系统的内存,JVM 不会再出现方法区的内存溢出;但是,无限制的使用会造成操作系统的死亡。一般也会使用参数 -XX:MaxMetaspaceSize 来控制大小。
JVM主内存与工作内存
主内存主要包括本地方法区和堆。每个线程都有一个工作内存,工作内存中主要包括两个部分,一个是属于该线程私有的栈和对主存部分变量拷贝的寄存器(包括程序计数器PC和cup工作的高速缓存区)。
1.所有的变量都存储在主内存中(虚拟机内存的一部分),对于所有线程都是共享的。
2.每条线程都有自己的工作内存,工作内存中保存的是主存中某些变量的拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
3.线程之间无法直接访问对方的工作内存中的变量,线程间变量的传递均需要通过主内存来完成。
JVM参数分类
- 标准参数,即在JVM的各个版本中基本不变的,相对比较稳定的参数
-help
-version -showversion
- X参数,非标准化参数,变化比较小的参数
-Xint:解释执行
-Xcomp:第一次使用就编译成本地代码
-Xmixed:混合模式,JVM自己来决定是否编译成本地代码,默认使用的就是混合模式
- XX参数,特点是非标准化参数,相对不稳定,主要用于JVM调优和Debug
1、Boolean类型
格式:-XX[+-]<name>其中+-表示启用或者禁用name属性
比如:-XX:+UseConcMarkSweepGC表示启用CMS垃圾收集器,-XX:+UseG1GC表示启用G1垃圾收集器
2、非Boolean类型
格式:-XX:<name>=<value>表示name属性的值是value
比如:-XX:MaxGCPauseMillis=500表示GC的最大停顿时间是500毫秒,-XX:GCTimeRatio=19
注意:-Xmx和-Xms表示设置JVM的最大内存和最小内存,它们不是X参数,而是XX参数,-Xmx等价于-XX:MaxHeapSize,-Xms等价于-XX:InitialHeapSize;-Xss设置堆栈,也是XX参数,等价于-XX:ThreadStackSize
Java类加载过程
类加载过程**
冯诺伊曼定义计算机模型 - 任何程序都要加载到内存才能与CPU进行交流。.class文件需要加载到内存中,才可以实例化类。
类加载实际上就是.class字节码文件实例化为Class对象并进行初始化的过程。JVM会初始化继承树上没被初始化的父类,执行没被执行过的静态代码块、静态变量赋值语句。
- Load:将外部的 .class 文件(二进制流),加载到 Java 的方法区内。
- Link:验证+准备+解析。详细验证类型是否正确,静态变量合理性;准备:为静态变量分配内存,设定默认值。解析:类与类之间相互引用正确性,完成内存结构布局。
- Init:执行类构造器方法。(如果通过其它类静态方法,则会去马上解析另外一个类,在虚拟机栈中执行返回)
详细7 个阶段:
- 加载(Loading)
通过类的完全限定名称获取定义该类的二进制字节流。
将该字节流表示的静态存储结构转换为方法区
的运行时存储结构。
在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口 - 验证(Verification)
Class 文件的字节流中包含的信息符合当前虚拟机的要求
准备(Preparation)
准备阶段为类静态变量分配内存并设置初始值
,使用的是方法区的内存。初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。
public static int value=123; //在准备阶段 value 初始值为 0 。在初始化阶段才会变为 123 。
但是加上final后就会初始化为123解析(Resolution)
将常量池的符号引用替换为直接引用的过程。- 初始化(Initialization)
执行类构造器
。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,通过程序制定的主观计划去初始化类变量和其它资源。 - 使用(Using)
-
Object ref = new Object();
利用javap -verbose -p命令查看对象创建的字节码:
NEW:如果找不到Class对象,则进行类加载。加载成功后在堆中分配内存,从Object到本类上的所有属性值都要分配内存。然后进行零值初始化,最后将实例对象的引用变量压入虚拟机栈顶。
- DUP:在栈顶复制该引用变量,栈顶有两个指向堆内实例对象的引用变量。底下的引用用于赋值,或保存到局部变量表。栈顶的引用变量作为句柄调用相关方法。
- INVOKESPECIAL:调用对象实例方法,通过栈顶的引用变量调用方法。
从执行步骤来看: - 确认类原信息是否存在。在metasapce内检查需要创建的类原信息,不存在的话,通过双亲委派模式,使用类加载器查找class文件。进行类加载
- 分配对象内存。计算对象占用空间大小,引用变量只需要分配4个字节。分配空间可以使用CAS保证原子性。
- 设定默认值。成员变量都需要设定默认值。(不同形式的零值)
- 设置对象头。
- 执行init方法。初始化成员变量,执行实例代码块,调用类的构造方法,堆内对象首地址赋值给引用变量。
类加载的主要步骤
• 装载。根据查找路径找到相应的 class 文件,然后导入。
• 链接。链接又可分为 3 个小步:
- 检查,检查待加载的 class 文件的正确性。
- 准备,给类中的静态变量分配存储空间。
- 解析,将符号引用转换为直接引用(这一步可选)
JVM 加载 Class 文件的原理
由 ClassLoader 和它的子类来实现的。类加载器本身也是一个类,其实质是把类文件从硬盘读取到内存中。类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(例如基类)完全加载到 JVM 中,至于其他类,则在需要的时候才加载。
类加载器分类
- 启动类加载器(
Bootstrap ClassLoader
)此类加载器负责加载 Java 核心类库,无法被 Java 程序直接引用。
将存放在\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。 - 扩展类加载器(
Extension ClassLoader
)这个类加载器加载 Java 的扩展库。
是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将/lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。 - 应用程序类加载器(
Application ClassLoader
)这个类加载器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是ClassLoader.getSystemClassLoader()
方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。 - 用户自定义类加载器,通过
继承 java.lang.ClassLoader
类的方式实现。
类的双亲委派模型
一个类加载器首先将类加载请求转发到父类加载器
,只有当父类加载器无法完成时才尝试自己加载。
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载
,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换
。
双亲委派模型破坏举例
tomcat
在同一个 JVM 里,运行着不兼容的两个版本,当然是需要自定义加载器才能完成的事。
那么 tomcat 是怎么打破双亲委派机制的呢? WebAppClassLoader,它加载自己目录下的 .class 文件,并不会传递给父类的加载器。但是,它却可以使用 SharedClassLoader 所加载的类,实现了共享和分离的功能。
SPI
通过在 META-INF/services 目录下,创建一个以接口全限定名为命名的文件(内容为实现类的全限定名),即可自动加载这一种实现,这就是 SPI。
在双亲委托模型下,类加载器是由下至上的,即下层的类加载器会委托上层进行加载。但是对于SPI来说,有些接口是JAVA核心库提供的,而JAVA核心库是由启动类加载器来加载的,而这些接口的实现却来自于不同的jar包(厂商提供),JAVA的启动类加载器是不会加载其他来源的jar包,这样传统的双亲委托模型就无法满足SPI的要求。而通过给当前线程设置上下文类加载器,就可以设置的上下文类加载器来实现对于接口实现类的加载
JDBC
原生的JDBC中Driver驱动本身只是一个接口,并没有具体的实现。在JDBC中的Driver类中需要动态去加载不同数据库类型的Driver类,而mysql-connector-.jar中的Driver类是用户自己写的代码,那启动类加载器肯定是不能进行加载的,既然是自己编写的代码,那就需要由应用程序启动类去进行类加载。于是乎,JDBC通过线程上下文件类加载器Thread.currentThread().getContextClassLoader()
得到线程上下文加载器来加载Driver实现类。)。有了这个东西之后,程序就可以把原本需要由启动类加载器进行加载的类,由应用程序类加载器去进行加载了。
class com.mysql.jdbc.Driver-----sun.misc.Launcher$AppClassLoader@18b4aac2
class com.mysql.fabric.jdbc.FabricMySQLDriver-----sun.misc.Launcher$AppClassLoader@18b4aac2
DriverManager classLoader:null
Java new一个对象的过程 (JVM创建对象)
- 首先到常量池中找类的带路径全名,然后检查对应的字节码是否已被加载,解析,验证,初始化,如果没有先执行类加载过程(class.forname())。
- 类加载过程完成后,虚拟机会为对象分配内存。分配内存有两种方式,根据使用的垃圾收集器的不同使用不同的分配机制。
(1)指针碰撞,当虚拟机使用复制算法或标记整理算法实现的垃圾收集器时,内存区域都是规整的,这时候使用指针碰撞分配内存,用过的内存放在一边,空闲的内存在另一边,中间用一个指针作为分界点,当需要为新对象分配内存时只需把指针向空闲的一边移动一段与对象大小相等的距离。
(2)空闲列表,当虚拟机使用标记清除算法实现的垃圾收集器时,内存都是碎片化的,那虚拟机就要记录哪块内存是可用的,当需要分配内存时,找一块足够大的内存空间给对象实例,并更新记录。
- 设置对象头信息,如所属类,元数据信息,哈希码,gc分代年龄,等等。
- 调用对象的init()方法,根据传入的属性值给对象属性赋值。
- 在线程栈中新建对象引用,并指向堆中刚刚新建的对象实例。
垃圾回收机制
- 标记 - 清除(老年代 CMS用这个回收)
在标记阶段,程序会检查每个对象是否为活动对象,是活动对象,则在对象头部打上标记。
在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表
” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。
在分配时,程序会搜索空闲链表
寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。
- 标记和清除过程效率都不高;
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
- 标记 - 整理(老年代)
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
- 优点是不会产生内存碎片
- 需要移动大量对象,处理效率比较低。
- 复制(新生代)
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
主要不足是只使用了内存的一半。
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。
现在的商业虚拟机划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。
垃圾分代收集
在不同年代使用不同的算法,从而使用最合适的算法。
新生代存活率低,可以使用复制算法。
老年代对象存活率高,没有额外空间对它进行分配担保,所以只能使用标记清除或者标记整理算法。
- 简化了新对象的分配(仅仅在新生代分配内存);
- 能够更有效的清除不再须要的对象(即死对象,新生代和老年代使用不同的回收算法)。
- 新生代对象一般分配在 Eden 区,当 Eden 区将满时,触发 Minor GC。
- 大部分对象在短时间内都会被回收, 所以经过 Minor GC 后只有少部分对象会存活,它们会被移到 S0 区(Eden: S0: S1 = 8:1:1),同时对象年龄加一,把 Eden 区对象全部清理。
- 触发下一次 Minor GC,把 Eden 区的存活对象和 S0中的存活对象移动到S1, 同时清空 Eden 和 S0 。
新生代使用
复制算法
,因为在 Eden 区对象大部分在 Minor GC 后都消亡,S0,S1 区域也比较小,降低了复制算法造成的对象频繁拷贝带来的开销
。对象何时晋升老年代
当对象的年龄达到了我们设定的阈值
- 大对象
- S0(或S1) 区相同年龄的对象大小之和大于 S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代
JVM优化,就是尽可能的让对象都在年轻代里分配和回收,尽量别让太多对象频繁进入老年代,避免频繁对老年代进行垃圾回收,同时给系统充足的内存大小,避免年轻代频繁的进行垃圾回收。
Stop-The-World机制
Java中Stop-The-World机制简称STW,在 GC(minor GC 或 Full GC)期间,只有垃圾回收器线程在工作,其他工作线程则被挂起
。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互。
可达性分析算法 - 判断对象是否可被回收
以 GC Roots 为起始点进行搜索,引出它们指向的下一个节点。。。直到所有的结点都遍历完毕,如果相关对象不在任意一个以 GC Root 为起点的引用链
中,则这些对象会被判断为「垃圾」,会被 GC 回收。
对象不可达(可回收)时,当发生GC时,会先判断对象是否执行了 finalize 方法,如果未执行,则会先执行 finalize
方法。 finalize 方法只会被执行一次,如果第一次执行 finalize 方法此对象变成了可达确实不会回收,但如果对象再次被 GC,则会忽略 finalize 方法,对象会被回收
!
有一种 JNI(Java Native Interface)调用non-Java 程序(C 或 C++), finalize() 的工作就是回收这部分的内存。
哪些对象可以作为 GC Roots
虚拟机栈
(栈帧中的本地变量表)中引用的对象 (Test a = new Test();)- 本地方法栈中 JNI(
Native
) 中引用的对象 方法区中类静态属性
引用的对象 (public static Test s;)方法区中的常量
引用的对象 (public static final Test s = new Test();)
能够找到 Reference Chain 的对象,就一定会存活么?
Java 提供了四种强度不同的引用类型。(强引用 软引用 弱引用 虚引用)
- 强引用:只有在和 GC Roots 断绝关系时,才会被消灭掉。
- 软引用:只有在内存不足时,系统则会回收软引用对象。
- 弱引用:无论内存是否充足,都会回收被弱引用关联的对象。
- 虚引用:必须和引用队列(ReferenceQueue)联合使用。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
内存泄露如何发生
长生命周期的对象持有短生命周期对象的引用
就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收。
缓存系统,我们加载了一个对象放在缓存中 (例如放在一个全局 map 对象中),然后一直不再使用它。
当一个对象被存储进 HashSet 集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,若修改contains就无法找到它啦。
垃圾回收器
JDK1.8默认 ( Parallel Scavenge + Parallel Old)
使用-XX:UseParallelGC的情况下,使用的垃圾收集器为:新生代(Ps Scanvenge),老年代(Ps MarkSweep,与Serial Old)。
Parallel Scavenge(多线程-复制):其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量。这里的吞吐量指 CPU 用于运行用户程序的时间占总时间的比值。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。
Parallel Old(多线程-标记整理):在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
CMS垃圾回收器(老年代) 并发+标记清除 - 最小的停顿时间
CMS是一款并发、使用标记-清除
算法、以获取最小停顿时间
为目的、对老年代
进行回收的GC。CMS虽然是老年代的gc,但仍要扫描新生代。(GC ROOT TRACING可到达)
- CMS垃圾回收器一般与
ParNew多线程收集器(新生代
, Serial 收集器的多线程版本)配合工作。
3.1 初始标记(STW) - 可达性分析,标记GC ROOT能直接关联
到的对象。
3.2 并发标记 - 由前阶段标记过的对象出发,所有可到达的对象都在本阶段中标记。(GC Roots Tracing,耗时最长 )+并发预清理 - 标记从新生代晋升的对象、新分配到老年代的对象以及在并发阶段被修改了的对象。
3.3 重标记(STW) - 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
3.4 并发清理 - 用户线程被重新激活,同时清理那些无效的对象。+ 重置 - CMS清除内部状态,为下次回收做准备。
优点:并发收集、低停顿
缺点:
- CMS收集器对CPU资源非常敏感。并发意味着多线程
抢占CPU资源
,即GC线程与用户线程抢占CPU。这可能会造成用户线程执行效率下降。吞吐量降低 - 并发清理阶段用户线程还在运行,这段时间就可能产生新的垃圾,新的垃圾在此次GC无法清除,只能等到下次清理。无法处理浮动垃圾。(不能等到老年代满了再进行GC)
- 标记-清除算法可能造成大量的空间碎片。往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。
G1垃圾回收器(同时在新老生代工作)- 可预测垃圾回收的停顿时间
G1 的思路: 它不要求每次都把垃圾清理的干干净净,它只是努力做它认为对的事情。 我们要求 G1,在任意 1 秒的时间内,停顿不得超过 10ms,这就是在给它制定 KPI。G1 会尽量达成这个目标,它能够推算出本次要收集的大体区域,以增量的方式完成收集。 这也是使用 G1 垃圾回收器不得不设置的一个参数: -XX:MaxGCPauseMillis=10
与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
在G1提出之前,经典的垃圾收集器主要有三种类型:串行收集器、并行收集器和并发标记清除收集器,这三种收集器分别可以是满足Java应用三种不同的需求:内存占用及并发开销最小化、应用吞吐量最大化和应用GC暂停时间最小化,但是,上述三种垃圾收集器都有几个共同的问题:(1)所有针对老年代的操作必须扫描整个老年代空间;(2)新生代和老年代是独立的连续的内存块,必须先决定年轻代和老年代在虚拟地址空间的位置。
初识G1
G1的设计原则是”首先收集尽可能多的垃圾
(Garbage First)”。
- G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
- 每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
- G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。
- G1还是一种带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩。每个分区的大小从1M到32M不等,但是都是2的冥次方。
新生代其实并不是适用于这种算法的,依然是在新生代满了的时候,对整个新生代进行回收——整个新生代中的对象,要么被回收、要么晋升,至于新生代也采取分区机制的原因,则是因为这样跟老年代的策略统一,方便调整代的大小。
GC模式
针对新生代和老年代,G1提供2种GC模式,Young GC和Mixed GC,两种会导致Stop The World
- Young GC 当新生代的空间不足时,G1触发Young GC回收新生代空间 Young GC主要是对Eden区进行GC,它在Eden空间耗尽时触发,基于分代回收思想和复制算法,每次Young GC都会选定所有新生代的Region,同时计算下次Young GC所需的Eden区和Survivor区的空间,动态调整新生代所占Region个数来控制Young GC开销
- Mixed GC 当老年代空间达到阈值会触发Mixed GC,选定所有新生代里的Region,根据全局并发标记阶段(下面介绍到)统计得出收集收益高的若干老年代 Region。在用户指定的开销目标范围内,尽可能选择收益高的老年代Region进行GC,通过选择哪些老年代Region和选择多少Region来控制Mixed GC开销
全局并发标记
全局并发标记主要是为Mixed GC计算找出回收收益较高的Region区域
4.1 初始标记(STW):暂停所有应用线程(STW),并发地进行标记从 GC Root 开始直接可达的对象(原生栈对象、全局对象、JNI 对象),当达到触发条件时,G1 并不会立即发起并发标记周期,而是等待下一次新生代收集,利用新生代收集的 STW 时间段,完成初始标记,这种方式称为借道(Piggybacking)
4.2 根区域扫描:在初始标记暂停结束后,新生代收集也完成的对象复制到 Survivor 的工作,应用线程开始活跃起来; 此时为了保证标记算法的正确性,所有新复制到 Survivor 分区的对象,需要找出哪些对象存在对老年代对象的引用,把这些对象标记成根(Root); 扫描的 Suvivor 分区也被称为根分区(Root Region);
4.3 并发标记:标记各个堆中Region的存活对象信息
4.4 重新标记(STW): 和CMS类似暂停所有应用线程(STW),以完成标记过程短暂地停止应用线程, 标记在并发标记阶段发生变化的对象,和所有未被标记的存活对象,同时完成存活数据计算。
4.5 清理(STW):整理更新每个Region各自的RSet;回收不包含存活对象的Region;统计计算回收收益高(基于释放空间和暂停目标)的老年代分区集合
- 空间整合:整体来看是基于
标记 - 整理
算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制
”算法实现的,这意味着运行期间不会产生内存空间碎片
。 - STW
可预测
的停顿:用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。 - g1 回收后立马合并空闲内存,CMS在stw的时候做
传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 的话,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小,这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。
STW 过程中,初始标记因为只标记 GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1 停顿时间的瓶颈主要是标记-复制中的转移阶段 STW。
为什么转移阶段不能和标记阶段一样并发执行呢?主要是 G1 未能解决转移过程中准确定位对象地址的问题。
标记-复制算法停顿时间瓶颈
CMS 新生代的 Young GC、G1 和 ZGC 都基于标记-复制
算法。标记-复制算法可以分为三个阶段:
- 标记阶段,即从 GC Roots 集合开始,标记活跃对象;
- 转移阶段,即把活跃对象复制到新的内存地址上;
- 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。
JDK11带来的全新的 ZGC
全并发的ZGC
ZGC 也采用标记-复制算法,但ZGC 在标记、转移和重定位阶段几乎都是并发的,这是 ZGC 实现停顿时间小于10ms目标的最关键原因。
- 初始化标记,和CMS以及G1一样,主要做Root集合扫描,「GC Root是一组必须活跃的引用,而不是对象」。
- 并发标记阶段,这个阶段在第一步的基础上,继续往下标记存活的对象。
- 选取接下来需要标记整理的Region集合
- 并发回收,这个阶段会把上一阶段选中的需要整理的Region集合中存活的对象移到一个新的Region中. (原来占用的Region就能马上回收并被用于接下来的对象分配。)
- 它与G1一样,都是基于Region设计的垃圾回收器,ZGC中的Region也被称为「ZPages」。G1的每个Region大小是完全一样的,而ZGC的Region大小分为3类:2MB,32MB,N×2MB。
- G1和ZGC在回收的时候,它们只会选择一部分Region进行回收,这个回收过程采用的是Mark-Compact算法,即将待回收的Region中存活的对象拷贝到一个全新的Region中,这个新的Region对象分配就会非常紧凑,几乎没有碎片。垃圾回收算法这一点上,和G1是一样的。
- ZGC是能自动感知NUMA架构并充分利用NUMA架构特性的。
ZGC 只有三个 STW 阶段:初始标记,再标记,初始转移。
其中,初始标记和初始转移分别都只需要扫描所有 GC Roots,其处理时间和 GC Roots 的数量成正比,一般情况耗时非常短;再标记阶段 STW 时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC 几乎所有暂停都只依赖于 GC Roots 集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与 ZGC 对比,G1 的转移阶段完全 STW 的,且停顿时间随存活对象的大小增加而增加。
ZGC关键技术(着色指针+读屏障技术)
ZGC 通过着色指针和读屏障技术
,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着 GC 线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在 ZGC 中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。
Load Barriers读屏障
(之前的GC都是采用Write Barrier)尝试读取堆中的一个对象引用obj.fieldA并赋给引用o(fieldA也是一个对象时才会加上读屏障)。如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW。- JVM是如何判断对象被移动过呢?就是利用上面提到的颜色指针,如果指针是Bad Color,那么程序还不能往下执行,需要「slow path」,修正指针;如果指针是Good Color,那么正常往下执行即可。(有点像CAS自旋)
ZGC 中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。
ZGC 调优实践
ZGC 的核心特点是并发,GC 过程中一直有新的对象产生。如何保证在 GC 完成之前,新产生的对象不会将堆占满,是 ZGC 参数调优的第一大目标。
开启”基于固定时间间隔“的 GC 触发机制
-XX:ZCollectionInterval:比如调整为5秒,甚至更短。
增大修正系数
-XX:ZAllocationSpikeTolerance,更早触发 GC。ZGC 采用正态分布模型预测内存分配速率,模型修正系数 ZAllocationSpikeTolerance 默认值为2,值越大,越早的触发 GC。Zeus 中所有集群设置的是5。
升级ZGC效果 (延迟降低,但吞吐量下降)
- 延迟降低
TP(Top Percentile)是一项衡量系统延迟的指标:TP999 表示99.9%请求都能被响应的最小耗时;TP99 表示99%请求都能被响应的最小耗时。
在 Zeus 服务不同集群中,ZGC 在低延迟(TP999 < 200ms)场景中收益较大:
超低延迟(TP999 < 20ms)和高延迟(TP999 > 200ms)服务收益不大,原因是这些服务的响应时间瓶颈不是 GC,而是外部依赖的性能。
- 吞吐量下降
对吞吐量优先的场景,ZGC 可能并不适合。例如,Zeus 某离线集群原先使用 CMS,升级 ZGC 后,系统吞吐量明显降低。究其原因有二:第一,ZGC 是单代垃圾回收器,而 CMS 是分代垃圾回收器。单代垃圾回收器每次处理的对象更多,更耗费 CPU 资源
;第二,ZGC 使用读屏障,读屏障操作需耗费额外的计算资源。
GC性能调优(减少GC+STW)
- 设置堆的最大最小值(Xmx和Xms),为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,我们通常把最大、最小设置为相同的值。
- 设置老年和年轻代的比例 ,为了防止年轻代的堆收缩,我们通常会把-XX:newSize -XX:MaxNewSize设置为同样大小。
- 如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,年老代应该适当增大。(A)本着Full GC尽量少的原则,让年老代尽量缓存常用对象,JVM的默认比例1:2也是这个道理 (B)通过观察应用一段时间,看其他在峰值时年老代会占多少内存,在不影响Full GC的前提下,根据实际情况加大年轻代.
- 配置好的机器使用并发收集算法。
- 每个线程默认开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般512K就足用。
可以通过下面的参数打Heap Dump信息。
-XX:HeapDumpPath
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:/usr/aaa/dump/heap_trace.txt
通过下面参数可以控制OutOfMemoryError时打印堆的信息
-XX:+HeapDumpOnOutOfMemoryError
FullGC内存泄露排查 jstack(查看线程)、
jmap(查看内存)和
jstat(性能分析)
- 大对象:系统一次性加载了过多数据到内存中(比如SQL查询未做分页),导致大对象进入了老年代。
- 内存泄漏:频繁创建了大量对象,但是无法被回收(比如IO对象使用完后未调用close方法释放资源),先引发FGC,最后导致OOM.
- 程序频繁生成一些长生命周期的对象,当这些对象的存活年龄超过分代年龄时便会进入老年代,最后引发FGC. (即本文中的案例)
- 程序BUG导致动态生成了很多新类,使得 Metaspace 不断被占用,先引发FGC,最后导致OOM.
- 代码中显式调用了gc方法,包括自己的代码甚至框架中的代码。
- JVM参数设置问题:包括总内存大小、新生代和老年代的大小、Eden区和S区的大小、元空间大小、垃圾回收算法等等。
JDK的自带工具,包括jstack(查看线程)
、jmap(查看内存)
和jstat(性能分析,堆内存各部分的使用量以及加载类的数量)
等常用命令:
#查看堆内存各区域的使用率以及GC情况
jstat -gcutil -h20 pid 1000
#查看堆内存中的存活对象,并按空间排序
jmap -histo pid | head -n20
#dump堆内存文件
jmap -dump:format=b,file=heap pid
dump
下来,用可视化的堆内存分析工具:JVisualVM
、JConsole、MAT等
线上问题
- OOM 问题:首先要配置
-XX:+HeapDumpOnOutOfMemoryError
,dump堆内存用MAT工具查看。
创建了大量线程、对象,导致垃圾回收器来不及回收,分配的堆内存被占满,产OutOfMemoryError错误。
产生栈溢出的场景:比如死循环中创建对象。
- 栈溢出(StackOverflowError):如果你确认递归实现是正确的,为了允许大量的调用,你可以增加栈的大小。依赖于安装的 Java 虚拟机,默认的线程栈大小可能是 512KB 或者 1MB。你可以使用
-Xss
标识来增加线程栈的大小。
Java的内存结构中,栈的大小不是无限的。大量的方法调用过程,导致不断压栈最终将栈内存占满,产生StackOverflowError错误,程序直接终止运行。
产生栈溢出的场景:比如不合理(递归太深)的递归调用。
- 内存泄漏 (1.尽量降低变量的作用域,以及及时把对象复制为可清理对象(null)2.在各种IO或者数据库连接时,都需要在最后通过close()方法释放对象,这里也是长对象引用短对象时造成的内存泄漏)
当很多对象使用之后已经没有再使用的必要而没有置为null,导致垃圾回收器无法对其回收,造成内存资源的大量浪费,给系统带来很多不稳定因素。
产生内存溢出的场景:比如使用静态的集合。
// 如果长生命周期的对象持有短生命周期的引用,就很可能会出现内存泄露。
public class Simple {
Object object;
void method () {
object = new Object();
}
}
- 线程死锁,锁争用
- Java进程消耗CPU过高 (top + top Hp + jstack + jmap)
(1)使用top -Hp <pid>
命令找出进程中占用cpu最高的前几个线程
(2)使用jstack获取线程快照
,然后使用线程id搜索分析快照文件
(3)如果线程调用了业务相关代码,则分析是否是代码问题导致的cpu占用过高,如果线程是VM Thread,则应该监控检查垃圾回收活动频率,看是否是因为频繁进行垃圾回收导致的。
cpu使用率和cpu负载区别
https://www.cnblogs.com/zhangwangvip/p/12626400.html
1、cpu使用率
CPU使用率指的是程序在运行期间实时占用的CPU百分比,这是对一个时间段内CPU使用状况的统计。通过这个指标可以看出在某一个时间段内CPU被占用的情况。
2、cpu负载
负载就是cpu在一段时间内正在处理以及等待cpu处理的进程数之和的统计信息,也就是cpu使用队列的长度统计信息,这个数字越小越好(如果超过CPU核心*0.7就是不正常)
负载分为两大部分:CPU负载、IO负载
cpu负载:假设有一个进行大规模科学计算的程序,虽然该程序不会频繁地从磁盘输入输出,但是处理完成需要相当长的时间。因为该程序主要被用来做计算、逻辑判断等处理,所以程序的处理速度主要依赖于cpu的计算速度。此类cpu负载的程序称为“计算密集型程序”。
IO负载:还有一类程序,主要从磁盘保存的大量数据中搜索找出任意文件。这个搜索程序的处理速度并不依赖于cpu,而是依赖于磁盘的读取速度,也就是输入输出(input/output,I/O).磁盘越快,检索花费的时间就越短。此类I/O负载的程序,称为“I/O密集型程序”。
高负载,低CPU使用率的情况
原因:等待磁盘I/O完成的进程过多,导致进程队列长度过大,但是cpu运行的进程却很少,这样就体现到负载过大了,cpu使用率低。
负载总结为一句话就是:需要运行处理但又必须等待队列前的进程处理完成的进程个数。具体来说,也就是如下两种情况:
等待被授权予CPU运行权限的进程
等待磁盘I/O完成的进程
cpu低而负载高也就是说等待磁盘I/O完成的进程过多,就会导致队列长度过大,这样就体现到负载过大了,但实际是此时cpu被分配去执行别的任务或空闲,具体场景有如下几种。
场景一:磁盘读写请求过多就会导致大量I/O等待
上面说过,cpu的工作效率要高于磁盘,而进程在cpu上面运行需要访问磁盘文件,这个时候cpu会向内核发起调用文件的请求,让内核去磁盘取文件,这个时候会切换到其他进程或者空闲,这个任务就会转换为不可中断睡眠状态。当这种读写请求过多就会导致不可中断睡眠状态的进程过多,从而导致负载高,cpu低的情况。
场景二:MySQL中存在没有索引的语句或存在死锁等情况
我们都知道MySQL的数据是存储在硬盘中,如果需要进行sql查询,需要先把数据从磁盘加载到内存中。当在数据特别大的时候,如果执行的sql语句没有索引,就会造成扫描表的行数过大导致I/O阻塞,或者是语句中存在死锁,也会造成I/O阻塞,从而导致不可中断睡眠进程过多,导致负载过大。
具体解决方法可以在MySQL中运行show full processlist命令查看线程等待情况,把其中的语句拿出来进行优化。
场景三:外接硬盘故障,常见有挂了NFS,但是NFS server故障
比如我们的系统挂载了外接硬盘如NFS共享存储,经常会有大量的读写请求去访问NFS存储的文件,如果这个时候NFS Server故障,那么就会导致进程读写请求一直获取不到资源,从而进程一直是不可中断状态,造成负载很高。
排查步骤
jmap -heap 233047
jstat -gcutil -h20 233047 1000
其它问题
java中的内存泄漏
内存泄露就是指一个不再被程序使用的对象或变量一直被占据在内存中。Java 使用有向图的方式进行垃圾回收管理,可以消除引用循环的问题,例如有两个对象,相互引用,只要它们和CG ROOTS不可达,那么GC也是可以回收它们的。
- 如果一个外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持久外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。
- 当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄露。
虚拟机如何判定两个Java类相同
不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。
为什么要指令重排序
因为一个汇编指令也会涉及到很多步骤,每个步骤可能会用到不同的寄存器,现在的CPU一般采用流水线来执行指令,也就是说,CPU有多个功能单元(如获取、解码、运算和结果),一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段,流水线是并行的, 第一条指令执行还没完毕,就可以执行第二条指令,前提是这两条指令功能单元相同或类似
,所以一般可以通过指令重排使得具有相似功能单元的指令接连执行来减少流水线中断的情况。
自动类型提升
当容量小的数据类型的变量与容量大的数据类型的变量做运算时,得到的结果自动提升为容量大的数据类型。
byte 、char 、short —> int —> long —> float —> double
特别的:当byte、char、short三种类型的变量互相做运算时,或者自己和自己本身做运算时,结果都为int型,这是规定;
int类型 和 int类型 做运算,结果还是int;
int类型 和 float 类型做运算,结果是flaot类型;
。。。。。。。。
反正就是范围小的类型和大的类型做运算,结果的类型都是大的的那个类型;
说一下byte a = 127, byte b = 127; a+=b 和a = a+b的区别分别会出现什么问题。
a+=b 会出现负数。
a=a+b 会直接报错。