JVM结构篇
-
jvm的发展历程
classic jvm >>> exact jvm >>> HotSopt 、 JRockit(最快,专注于服务端)、J9
HotSpot虚拟机结构如下:
一、类加载子系统
从本地或网络中加载class文件(文件开头有特定的文件标识)
三个阶段:
加载、连接、初始化
1.加载阶段(Loading)
从各个途径(本地、网络、数据库等)加载.class文件(二进制流),最终生成java.long.Class类
类加载器分为两个类型:
- 引导类加载器(BootstrapClassLoader)
- 用户自定义加载器(ExtensionClassLoader、SystemClassLoader、User-DefinedClassLoader)
引导类加载器(BootstrapClassLoader):是由c/c++编写的,无法通过代码直接获取到,用于加载核心类库中的类。
用户自定义加载器:是由java语言编写的,派生于ClassLoader类;可以通过代码直接获取到。
代码示例:
public class demo01 {
public static void main(String[] args) {
// 获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
// sun.misc.Launcher$AppClassLoader@18b4aac2
// 获取扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);
// sun.misc.Launcher$ExtClassLoader@1b6d3586
// 获取引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);
// null
// 获取当前自定义类的加载器
ClassLoader demoClassLoader = demo01.class.getClassLoader();
System.out.println(demoClassLoader);
// sun.misc.Launcher$AppClassLoader@18b4aac2
// 获取String类的类加载器
ClassLoader classLoader = String.class.getClassLoader();
System.out.println(classLoader);
// null
}
}
双亲委派机制:
java虚拟机对class文件采用按需加载
方式,并采用双亲委派模式加载。
- 当一个类需要被加载时,默认会使用系统类加载器加载;
- 但此时系统类加载器不会加载,而是交由其父类加载器加载,直到其没有父类加载器;
- 到达最顶的父类加载器后,父类加载如果能够加载则由该父类加载器加载,否则向下由子类加载器加载,直到使用默认加载器加载。
比如:自定义一个java.long.String类,在实例化String类的时候有如下过程:
- 默认加载器(系统类加载器)将加载请求委托给器父类加载器(扩展类加载器);
- 扩展类加载器将加载请求交给其父类加载器(引导类加载器);
- 引导类加载器没有父类加载器,所以判断自己能否加载该类;由于引导类加载器可以集在核心类库(java、javax、sun等开头的类),所以可以加载该类;但是,引导类加载器会从自己的路径中去加载这个类,所以机会加载核心类库中String,而不是自定义String类。
双亲委派机制优势:
- 避免类重复加载
- 保护程序安全,防止核心API被篡改
沙箱安全机制:保护程序运行不会被影响和破坏。
判断两个class是否为同一个类:
- 全类名一致(包括类名和包名)
- 加载这个类的类加载器ClassLoader必须一致。
2.连接阶段
验证阶段(Verify):
根据加载到的class文件,对其进行验证是否符合java虚拟机的规范(如文件开头的标识’CA FE BA BE‘)
准备阶段(Prepare):
为类变量
分配内存并且设置默认初始值,即零值。
如果是常量,即final static修饰,则在编译阶段就已经赋值。
实例变量不会初始化。
解析阶段(Resolve):
将常量池中的符号引用替换为直接引用,并翻译到具体的内存地址中。
直接引用
:直接引用可以是直接执行目标的指针、相对偏移量或一个能间接定位到目标的句柄。直接引用是与虚拟机实现内存布局相关的。有了直接引用说明目标一定已近在内存中了。
3.初始化
执行类构造器方法
()的过程。
该方法不需要定义,该方法会自动收集类中的所有变量的赋值动作和静态代码块合并起来。
不同于类的构造器
():()只在有静态代码块时,在初始化过程中运行;()任何类都会执行,且父类先于子类。
二、运行时数据区
- 每个线程独立包括程序计数器、栈、本地栈。
- 线程间共享:堆、堆外内存(永久代或元空间、代码缓存),调优主要在这两个区域内优化。
1.程序计数器
又称PC寄存器(软件模拟,非硬件层面)。
作用:PC寄存器用来存储指向下一条指令 的地址,也就是即将要执行的命令代码。由执行引擎读取下一条指令。
为什么需要使用PC寄存器?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就需要知道从那开始继续运行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
为社么PC寄存器时私有的?
为了准确的记录各个线程当前的正在执行的字节码指令,最好就是每个线程一份,各自记录自己线程的字节码指令,从而不会导致混乱。
2.虚拟机栈
在java虚拟机中,栈负责程序运行,堆负责存储数据(不绝对)。
Java虚拟机栈有如下特征:
- 基本单位为栈帧(Stack Frame),对应一次次的方法调用
- 是线程私有的(与程序计数器一样)
- 生命周期与线程一致
栈中可能抛出的异常:
- StackOverflowError:栈大小不足
- OutOfMemoryError:内存大小不足
栈的大小可以改变:
使用命令:
# 修改栈大小为2048k
-Xss2048k
:run > Edit Config > VM Option > 输入命令 > apply
运行如下程序课比较修改前后所执行的次数:
private static int count = 1;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args); // 会抛出StackOverflowError异常
}
栈的结构:
栈中的数据是以栈帧的格式存在的,一个方法对应一个栈帧。
java方法有两种返回方式:1.正常返回:return;2.抛出异常 两种方式都会使栈帧被弹出。
栈帧包含:
- 局部变量表(LV)
- 操作数栈(表达式栈)
- 动态链表(指向运行时常量池的方法引用)
- 方法返回地址(方法正常退出或异常退出的定义)
- 附加信息
局部变量表:
- 又称为局部变量数组或本地变量表。
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量
,基本单位是Slot
(变量槽),包括基本数据类型、对象引用以及返回地址类型。- slot:32位以内的类型占一个slot;64位的类型占两个slot(doublr,long)
- 构造方法或实例方法(非静态方法)的局部变量表的index为0的位置存放的是
this
,所以可以使用this;而静态方法的局部变量表中没有存放this,所以静态方法中不允许使用this。
操作数栈:
- 使用数组实现。根据字节码指令往操作数栈中写入或提取数据。
- 主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间。
栈顶缓存技术
:将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读、写速度,提升执行引擎的执行效率。
动态链接:
- 即:指向
运行时常量池
的方法引用。 - 动态连接的作用就是为了将这些符号引用转换为调用方法的直接引用。
如下方法示例代码:
public class DynamicLinking {
int a = 1;
public void methodA(){
}
public void methodB(){
methodA();
a++;
}
}
经过javap指令后得到:
静态连接(早期绑定)与动态链接(晚期绑定):
- 早期绑定:在编译期就确定调用哪个方法(符号引用转到方法引用)
- 晚期绑定:在运行时才能确定调用哪个方法
能够实现子类间的多态的方法一般就是虚方法。
非虚方法:如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可改变的,则为非虚方法。
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
方法调用指令:
- invokestatic:调用静态方法,非虚方法
- invokespecial:调用方法,非虚方法
- invokevirtual:调用虚方法
- invokeinterface:调用接口方法
动态语言与静态语言:
- 动态语言:在运行期间检查,如:js、python
- 静态语言:在编译期间检查,如:java
incokedynamic指令:用于处理动态语言,如Java8中的lambda表达式。
虚方法表:用于保存各个方法的实际入口,以提高性能。
方法返回地址:
方法返回地址主要针对于正常退出的情况。
当方法正常退出后,需要返回到方法被调用的位置,所以使用调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址
;而异常退出的,返回地址需要通过异常表类确定。
3.本地方法栈
有时候java应用需要与外部环境交互(操作系统,解释器等),所以需要使用本地方法。
本地方法:Native Method 就是一个Java方法,方法的实现由非java语言实现,如C。native修饰的方法不是抽象方法。
java虚拟机栈用于管理java方法的调用,本地方法栈用于管理本地方法的调用。
- 线程私有
- 可改变栈的大小
与虚拟机栈类似。
4.堆
1.核心概念
- 一个JVM实例只存在一个堆内存,堆内存也是Java内存管理的核心区域。
- java堆区在JVM启动的时候即被创建,其空间大小也就确定了,堆内存的大小是可以调节的。
2.内存细分:
垃圾收集器大部分都基于分代收集
理论设计。
- java7及之前:新生区+养老区+永久代
- java8及之后:新生区+养老区+元空间
逻辑上分为三部分,但实际上只有新生区和老年区。
3.对空间大小设置:
使用命令,设置堆区(新生代+老年代)大小:
# 堆区的初始内存 memory start
-Xms10m
# 堆区最大内存
-Xmx10m
可以使用VisualVM
程序查看其使用情况。
默认对空间的大小:
- 初始内存大小:物理电脑内存大小的/64
- 最大内存大小:物理电脑内存大小的/4
代码验证:
// 堆内存总量
long totalMemory = Runtime.getRuntime().totalMemory()/1024/1024;
// 堆最大内存
long maxMemory = Runtime.getRuntime().maxMemory()/1024/1024;
System.out.println("堆内存总量为:" + totalMemory + "M");
System.out.println("堆内存最大为:" + maxMemory + "M");
// 产看物理系统的内存大小
System.out.println("系统内存总量为:" + totalMemory*64/1024 + "G");
System.out.println("系统内存总量为:" + maxMemory*4/1024 + "G");
使用命令行查看内存使用情况:
jps
jstat -gc 进程号
如:
4.可能出现的异常:
OOM:OutOfMemoryError,内存溢出。
5.新生代与老年代:
- 新生代
- 新生代又分为:Eden空间、Survior0空间、Survior1空间(或from区、to区)
- 老年代
默认新生代与老年代在对结构中的比例为1:2,即新生代占堆空间1/3 -XX:NewRayio=2
默认新生代中Eden与两个Survivor的比例为8:1:1 -XX:Survivor=8
几乎所有对象都是在Eden区被new出来的,如果创建的对象在Eden中存不下则是例外。
-Xmn100m:显示设置新生代大小,一般不设置。
6.对象分配过程
一般过程如下:
- 新创建的对象会存放在Eden区;
- 当Eden区放满时,就会触发Y/Minor GC垃圾回收机制:对于没有用(死亡)的对象将会被清除,而依然有用(存活)的对象将会被放入Survivor空间中(from区),注意此时另一个Survicor空间(to区)为空的;
- GC完成后,Eden区为空,此时又可以放入新的对象。当再次被放满时,就再次触发GC,并检查Eden区中的对象是否有用,但是,对于Survivor中(from区)的对象则是判断是否依然有效,如果有效则放入另一个Survivor中(to区)并将该对象的年龄+1,同时将Eden区中的有效的对象放入to区;完成后Eden区和原来的from区清空,并使原来的from区变为to区,而原来的to区变为from区;
- 如此重复第3步;
- 当出现特殊情况时:即Eden区再次满了,再次调用Minor GC,在判断Survivor空间中的对象的时候,有些对象的年龄为15(达到阈值)时,将会把这些对象放入(Promotion)老年区中,而不是继续放入to区。
其他特殊情况:
- 对于超大对象(Eden区放不下),则直接放入老年区。如果老年区也放不下,就会触发Full GC回收老年区中的垃圾;如果还是放不下就会报OOM。
- 如果在Minor GC的时候,Survivor1/2中已经放满,导致Eden区中的存活的对象无法放入Survivor区中,则将该对象直接放入老年区。
对于Survivor空间,谁是空的谁就是to区,否则就是from区。
关于垃圾回收:频繁在新生区收集,很少在老年区收集,几乎不在永久区/元空间收集。
常用的调优工具:
- JDK命令行
- Eclipse:Memory Analyzer Tool
- Jconsole
- VisualVM
- Jprofiler
- Java Flight Recorder
- GCViewer
- GC Easy
Minor GC、Major GC 、Full GC
在HotSpot VM中,按照回收区域分为两大种类:
-
部分收集(Partial GC):
-
新生代收集(Minor GC / Young GC):
只是新生代(Eden、S0、S1)的垃圾收集。
只有Eden满时才触发Minor GC,Survivor满时不会触发。
-
老年代收集(Major GC / Old GC):
只是老年代的垃圾收集。目前,只有CMS GC会有单独收集老年代的行为;
注意:
很多时候Major GC和Full GC会混淆使用,需要具体分辨是老年回收还是整堆回收。 -
混合收集(Mixed GC):
收集整个新生代以及部分老年代的垃圾收集,只有G1 GC有此功能。
-
-
整堆收集(Full GC):
收集整个java堆和方法区的垃圾收集
Major GC的速度会比Minor GC的速度慢10倍以上,所以Major GC很费时间。
TLAB:
TLAB机制:在JVM中,每个进程的所有线程共享堆空间,所以当多个线程操作同一个堆内存地址时,该内存是不安全的;为了保证其安全,但是使用锁又会影响到运行效率,所以采用为每一个线程再Eden空间分配单独的空间使用,即为TLAB。
JVM将TLAB作为内存分配的首选, 每个TLAB所占的内存很小。 当单独的空间不够时,再使用公共空间。一旦对象再在TLAB空间分配内存失败时,JVM就会尝试通过使用加锁机制确保数据操作的原子性,从而在Eden空间中分配内存。
7.堆空间参数设置:
- -XX:+PrintFlagsInitial:查看所有参数的默认值
- -XX:+PrintFlagsFinal:查看所有参数的最终值
- -Xms:设置初始堆空间内存(默认为物理内存的1/64)
- -Xmx:设置最大空间内存(默认为物理内存的1/4)
- -Xmn:设置新生代的大小
- -XX:NewRatio:设置新生代与老年代的对结构的占比(默认为1:2)
- -XX:SurvivorRatio:设置新生代中Eden空间与Survivor的占比(默认为8:1:1)
- -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
- -XX:+PrintGCDetails:输出详细的GC处理日志
- -XX:+PrintGC:输出gc简要的信息
- -XX:HandlePromotionFailure:是否设置空间分配担保
空间分配担保(JDK7及以后该参数不再生效,始终为true):
在执行Minor GC之前,虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象的总空间。
- 如果大于:者Minor GC是安全的(最坏情况下将所有新生代中的对象放入老年区中)
- 如果小于:则会查看XX:HandlePromotionFailure参数的值是否允许担保失败。
- 如果设置为=true,那么就会检查老年的最大连续空间是否大于历次晋升到老年代的对象大小的平均值。
- 如果大于,则进行Minor GC,但是存在风险。
- 如果小于,则改为进行一次Full GC。
- 如果设置为=false,那么直接改为执行一次Full GC。
- 如果设置为=true,那么就会检查老年的最大连续空间是否大于历次晋升到老年代的对象大小的平均值。
8.逃逸分析
- 当一个对象在方法中被定义以后,对象只在方法内部使用,则认为没有发生逃逸。
则可以使用栈上分配。
- 当一个对象在方法内部被定义以后,他被外部方法所引用,则认为发生逃逸。
``判断是否发生逃逸,标准就是new的对象的实体是否有可能在方法外被调用。`
参数设置为:-XX:DoEscapeAnalysis 默认开启
代码优化:
-
栈上分配:当进行逃逸分析后,发现没有逃逸则可以使用栈上分配。 随着栈空间的回收,局部变量也会被回收,就不需要使用GC。
测试:
public class StackAllocation { public static void main(String[] args) { long start = System.currentTimeMillis(); // 循环100000000次,并创建100000000个对象(不发生逃逸) for(int i=0; i<100000000; i++){ alloc(); } long end = System.currentTimeMillis(); // 输出消耗时间 System.out.println("花费时间:" + (end -start) + "ms"); try { Thread.sleep(1000000); } catch (InterruptedException e) { e.printStackTrace(); } } private static void alloc() { User user = new User(); } } class User{ } // 使用参数开启逃逸分析:-Xms1G -Xmx1G -XX:+DoEscapeAnalysis 耗时7ms // 使用参数关闭逃逸分析:-Xms1G -Xmx1G -XX:-DoEscapeAnalysis 耗时60-67毫秒 // 如果将内存参数该小,则开启逃逸分析不会发生GC,而关闭逃逸分析会发生GC
-
同步省略:逃逸分析会判断同步代码块所使用的锁对象是否只被一个线程所使用,如果不会被其他线程对象使用就会消除同步,从而提高性能。
-
标量替换:无法被分解为更小的数据的数据被称为标量,java中的原始数据类型就是标量。而可以分解的数据就叫做聚合量(java中的对象)。经过逃逸分析,发现一个对象不会被外界访问的或,就会把这个对象差分为若干标量存储在栈帧的局部变量表中,这个过程称为标量替换。
参数设置:-XX:EliminateAllocations 默认打开,允许将对象打散分配在栈上。
测试:
public class ScalarReplace { public static class User{ public String name; public Pet pet; } public static class Pet{ public String name; public int age; } // 创建对象且对象只在方法中使用 public static void alloc(){ User user = new User(); Pet pet = new Pet(); pet.name = "cat"; pet.age = 1; user.name = "zhangsan"; user.pet = pet; } public static void main(String[] args) { long start = System.currentTimeMillis(); for(int i=0; i<10000000; i++){ alloc(); } long end = System.currentTimeMillis(); System.out.println("消耗时间:" + (end-start) + "ms"); } } // 使用参数关闭标量替换 :-Xms1G -Xmx1G -XX:-EliminateAllocations -XX:+PrintFlagsFinal 消耗时间为300-320ms // 使用参数打开标量替换 :-Xms1G -Xmx1G -XX:+EliminateAllocations -XX:+PrintFlagsFinal 消耗时间为16ms
所以能使用局部变量的,就不要再方法外定义:因为1.定义为属性后可能不会被GC了,浪费空间;2.定义为局部变量后,可以优化,例如栈上分配,当方法结束后,变量也在随着栈帧一起出栈,就不需要GC回收了,提高效率。
5.方法区
方法区看作是独立于堆的内存。
方法区是各个线程共享的内存区域。
方法区的大小决定了可以定义类的数量,当方法区的内存不足时/溢出时就回报OOM(JDK8:Metaspace,JDK7:PermGen Space),加载大量的第三方jar包、Tomcat部署过多的项目。
1.方法区的演进
- JDK7及以前称为永久代
只有在HotSpot虚拟机中方法区等价于永久代,其他虚拟机中并没有永久代。
-
JDK8及以后称为元空间
元空间与永久代的本质区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。
方法区演进细节
主要针对Hotspot虚拟机。
- JDK1.6及之前:有永久代,静态变量存放在永久代上;
- JDK1.7:有永久代,但逐渐“去永久代”,字符串常量池、静态变量移除、保存在堆中;
- JDK1.8及以后:无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量任在堆。
2.设置方法区大小
JDK7及以前:
- 使用-XX:PermSize 来设置永久代初始分配空间。默认值20.75M。
- 使用-XX:MaxPermSize来设置最大可分配空间。
JDK8及以后:
-
使用-XX:MetaspaceSize 来设置元空间大小,默认21M
设置的初始的元空间大小为高水平线,一旦触及这个高水平线,Full GC就会被触发并清理没有用的类,当清理掉的空间很大时则会适当降低该值,否则适当增加该值。
-
使用-XX:MaxMetaspaceSize 来设置最大元空间大小
3.方法区内部结构
方法区内主要存放:类型信息(类信息、域信息、方法信息)、常量、静态变量、即使编译器编译后的代码缓存。
回顾:常量(static final修饰)在加载的链接阶段中的prepare阶段就已经赋值,而static修饰的类变量在初始化阶段才赋值。
4.运行时常量池
方法区中包含了运行时常量池,而字节码文件中包含了常量池表。常量池表加载后就存放到方法区的常量池中。
在代码运行时会用大量的引用,如果全部放在字节码文件中,那么文件可能会非常大且有可能会重复,而常量池中就是用了符号引用,使用符号引用就更为方便。
简而言之:常量池可以看作是一张表,虚拟机指令根据者张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
5.方法区垃圾回收
主要回收常量池中废弃的常量(字面量、符号引用)和不再使用的类。
6.附1:对象的实例化
1.对象的实例化
对象创建的方式:
- new:常用方式,变形有单例模式或工厂模式;
- Class的newInstance():反射方式,只能调用无参、public构造;
- Contructor的newInstance(xxx):可调用有空参、有参、任意权限的构造器;
- clone():通过克隆来创建新的对象;
- 反序列化:本地或远程的对象传递过来;
- 第三方库Objenesis。
2.对象的创建步骤:
- 判断对象对应的类是否加载、链接、初始化
- 为对象分配内存
- 如果内存规整:使用指针碰撞(指针向后移)
- 如果内存不规整:虚拟机需要维护一个“空闲列表”,从中找到一个能够存放对象的空间来存放对象
- 处理并发安全问题:采用CAS失败重试、区域枷锁保证更新的原子性;每个线程预先分配一个TLAB
- 初始化分配到的空间:所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用。
- 设置对象的对象头
- 执行Init方法进行初始化
对象属性赋值的步骤:
属性的默认初始化 >> 显示初始化 >> 代码块初始化 >> 构造方法初始化
2.对象的内存布局
-
对象头:
- 运行时元数据
- 哈希值
- GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
- 类型指针:指向类元数据,确定该类对象所属的类型
- 运行时元数据
-
实例数据:
存储对象的有效信息,包括定义的各种类型和字段(父类及本身)
-
对齐填充:起占位符作用
3.对象访问定位
- 句柄访问
- 直接指针(Hotspot使用)
7.附2:直接内存
三、执行引擎
将加载得到的字节码"翻译"成机器指令进行执行。java语言是半编译半解释型语言。
解释器:
当java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容"翻译"为本地机器指令执行。
JIT编译器:
虚拟机将源代码直接翻译成和本地机器相关的机器语言。
解释器的响应速度块,jvm启动后就开始解释运行;而JIT编译器需要等待将字节码全部编译成机器指令后才能执行,但是一旦编译完成后运行速度是很快的。
热点探测功能:
HotSpot虚拟机采用热点探测计数器的热点探测,每个方法拥有方法调用计数器(记录方法被调用次数)和回边计数器(记录循环体的循环次数)。当计数器达到一定数量时就被认定为热点代码,就会由JIT编译器
编译运行并保存在方法区
的代码缓存中。
热度衰减:当到达一定时间后,热度会下降。
方法计调用数器阈值:Client端:1500次;Server端:10000次。使用参数:-XX:CompileThreshold设定
HotSpot模式设置:
命令行设置:
- java -Xint:只是用解释器
- java -Xcomp:只是用编译器
- java -Xmixed:混合使用
四、String
String具有不可变性。字面量方式声明的字符串保存在字符串常量池中(存放在堆中)。字符串常量池中不会存储内容相同的字符串
。
JDK8及以前,使用char[ ]数组保存;在JDK9以后使用byte[ ]数组保存。
-
常量与常量的凭拼接结果在常量池,原理是编译期优化。
-
常量池中不会存在相同内容的常量。
-
只要其中有一个是变量,结果就在堆中,变量拼接的原理时StringBuilder。
1.new一个StringBuilder 2.使用append()方法添加 3.调用toString()返回字符串
-
如果拼接结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
intern()的使用:
是一个native方法。
调用intern()后,回去判断当前的字符串是否在字符串常量池中,如果不存在就在字符串常量池中创建一个,并将其地址返回以指向常量池;如果有,则基本不做操作。
String str = new String("a");
str.intern();
String ste1 = "a";
System.out.println(str == str1); // false
// 第一行代码,new一个字符串会产生两个对象,分别是new的对象本身和字符串常量池中的"a"。
// 第二行代码调用intern()方法,会先判断字符串常量池中是否有"a",显然是有的,所以intern()方法基本没有作用,str依然是指向堆中的new的对象。
// 即:只有在字符串常量池中没有的常量,调用intern()方法才会返回常量池中的地址。
String s = new String("a") + new String("b");
// 1.会创建StringBuilder
// 2.会创建new("a")对象和常量池中的"a";
// 3.会创建new("b")对象和常量池中的"b";
// 4.会调用toString()方法,new一个"ab"对象,但是不会产生常量池中的"ab"
jdk1.6:会将这个字符串对象放入字符串常量池。
- 如果常量池中已有,则不会放入,返回已有的常量池中的对象的地址;
- 如果没有,则把此对象复制一份,放入常量池,并返回常量池中的地址。
jdk1.7以后:
- 如果常量池中已有,则不会放入,返回已有的常量池中的对象的地址;
- 如果没有,则会把对象的引用地址复制一份,放入常量池,并返回该引用地址。
例1:
String s = new String("a") + new String("b");
String s2 = s.intern();
System.out.println(s2 == "ab");
System.out.println(s == "ab");
System.out.println(s == s2);
例二:
String s1 = new String("a") + new String("b");
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2); //jdk6:false jdk7:true
例二改:
String s1 = new String("ab");
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2); //jdk6:false jdk7:false
当程序中出现大量重复的String时,使用intern()方法可以节省大量空间。(会进行垃圾回收)
垃圾回收篇
垃圾收集频率:
- 频繁收集新生代
- 较少收集老年代
- 基本不动方法区
一、垃圾回收算法
只有在堆和方法区才有GC,且方法区不是一定要GC。
1.标记阶段:引用计数算法
前提介绍:
在垃圾回收之前,需要判断哪些对象是存活的,哪些对象是已经死了的。只有在标记了死亡的对象之后才能准确的进行垃圾回收,释放内存空间,而这个标记的过程称为垃圾标记阶段
。
对象死亡
:当一个对象已经不再任何的存活对象继续引用时,就可以判断为死亡。
引用计数算法:
对于每个对象保存一个整型的引用计数器属性
。用于记录该对象被引用的情况。
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延时性。
缺点:
- 计数器存储空间开销;
- 加减法的时间开销;
- 无法处理循环引用问题
循环引用问题:
由于循环引用问题,Java中不使用该算法
。
2.标记阶段:可达性分析算法
又称跟搜索算法、追踪性垃圾收集
能够解决循环引用问题。
可达性分析算法
以一些GC Roots作为根节点向下搜索,能直接或间接被搜索到的对象即为存活对象,而没有被搜索到的对象为死亡对象。
GC Root可以是:
- 虚拟机栈中引用的对象:如方法中调用的参数、局部表变等;
- 本地方法栈中的引用对象;
- 方法区中类静态属性引用的对象:如引用类型的静态变量;
- 方法区中常量引用的对象;
- 同步锁持有的对象;
- 异常对象、系统类加载器等;
- 临时加入的对象:如分代收集时新生代引用老年代的对象。
概括为:堆中对象之外的对象可以成为GC Roots。
在使用可达性分析算法时,需要保证程序的一致性,所以需要"Stop The World "
来使用户线程停止以保证一致性。
finalize()方法
finalize()方法在GC开始之前被调用。
不要主动调用finalize()方法。
对象回收时有三个状态:
- 可触及的:即存活,GC Roots可到达;
- 可复活的:对象不再被任何引用,但是可以调用finalize()方法后复活;
- 不可触及的:对象的finalize()方法被调用且没有复活,那么就会进入不可触及状态。该对象不能再被复活,因为
finalize()方法只能被调用一次。
因此,对象被回收需要经历至少两次标记过程。
3.清除阶段:标记-清除算法
当内存分区中的存活对象与死亡对象标记出来后,就需要堆垃圾进行回收释放空间,该过程称为垃圾清除阶段。
标记清除算法(Mark-Sweep)
标记清除算法经历两步,分别是:
- 标记:Collector把所有的被引用的对象进行标记;一般是在对象的Header中记录为可达对象。
- 清除:Collector对对内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
特点
算法简单,容易实现。
缺点:
- 效率不高
- GC的时候需要停止程序
- 清理出来的内存空间是不连续的,需要额外维护一个
空闲列表
。
这里所谓的清除不是指将内存中的数据清空(物理清除),而是将这些垃圾存放的地址放在空闲列表中,待后面需要使用内存的时候,直接覆盖即可。
4.清除阶段:复制算法
将内存分为两块并确保只是用其中一个内存块,通过GC Roots查询并判断哪些是存活的对象并将存活的对象复制
到另一个内存块中,而死亡的对象则直接清除即可。如此可以保证每个对象的存放地址都是连续的(指针碰撞),不会产生内存碎片。
使用场景:新生代的Survivor0、Survivor1区的复制算法。
优点:实现简单,运行高效。不会产生内存碎片。
缺点:需要两倍的内存空间。由于是复制对象,所以对象存储的地址会变化从而导致栈中的对象地址引用也需要改变。
不适用于需要大量复制的场景。
所以新生区分为Eden区和Survivor区。
5.清除阶段:标记-压缩算法
又称标记-整理、Mark-Compact算法
由于使用标记-清除算法存在大量的内存碎片,所以并不适用于老年代。而复制算法消耗的内存较大且老年代中的对象有又长期存活,所以又不是用于老年代。所以产生了标记-压缩算法。
标记-压缩算法分为两个步骤:
- 标记:根据GC Roots查找判断并标记存活的对象。
- 压缩:将未标记的对象清除,将被标记的对象整理放到内存的一段使其占用内存地址连续。
优点:解决了上面两种算法的缺点。
缺点:效率相对于标记-清除算法低;需要修改被引用对象的地址。STW时间略长。
三种算法比较:
执行效率 | 空间开销 | 内存整齐 | |
---|---|---|---|
标记清除 | 较低 | 小 | 否 |
标记整理 | 最低 | 小 | 是 |
复制算法 | 高 | 大 | 是 |
6.分代收集算法
不同生存周期的对象使用不同的算法以实现性能最优。
在HotSpot中:
-
年轻代特点:内存空间较老年代小,对象存活时间短、存活率低,触发GC的次数多。
如上所述,由于对象存活时间短(存活对象少)且触发GC次数多,是要求效率高所以使用复制算法。
-
老年代特点:区域大,对象存活时间长,触发GC机会少。
如上所述,由于老年代的对象不要常常回收,所以复制算法不合适,所以一般使用标记-清除和标记-整理配合使用。
二、垃圾回收相关概念
1.System.gc()
调用System.gc()时,会触发Full GC
同时对新生代个老年代的垃圾进行回收。但是无法保证每次都能调用垃圾回收器。
- 与Runtime.getRunTime().gc()功能相同。
- System.runFinalization() 强制调用对象finalize()方法
2.内存溢出与内存泄漏
内存溢出(OOM)
内存溢出指没有空闲的内存,并且垃圾收集器也无法提供更多的内存。
造成的原因:
- 初始设置的内存空间太小
- 存在大量的长期存活的对象不能被回收
内存泄露(Memory Leak)
当对象不再被使用了,但是又不能被GC回收的情况叫做内存泄漏。
内存泄漏可能会造成OOM。
如:外部资源连接时由于没有关闭而导致内存泄漏,因为这些资源会一直存在到程序结束。
3.STW
即:Stop the World
在GC过程中需要产生应用停顿导致用户线程无法运行。
STW是为了保证垃圾回收时保证一致性,如:GC Roots需要在GC时数量确定从而保证更好的垃圾回收。
4.安全点与安全区域
安全点(safepoint)
只有在特定位置才能让用户线程停止下来进行GC,而这些位置称为安全点。
可以选择一些处理时间较长的指令作为安全点,如:方法调用、循环跳转等。
安全区域(Safe Region)
安全区域指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。
5.引用
继承于:java.lang.ref;
以下四种引用强度依次减弱
强引用(Strong Reference)
即传统意义上的引用:Object obj = new Object();在任何情况下,只要对象被引用(可达)的就不会被回收。
强引用是造成内存泄漏的主要原因。
软引用(Soft Refrenfe)
当内存不足时,如果对象存在软引用,则会将其列入回收范围进行二次回收。
用于描述还有作用,但是不是必须的对象。
弱引用(Weak Reference)
只要垃圾收集器工作时,弱引用的对象就会被回收。
可以用于做缓存。
虚引用(Phantom Reference)
虚引用对对象的生存周期没有影响,只是作为对象回收的跟踪。
三、垃圾回收器
1.概述
GC性能指标
- 吞吐量:用户代码运行时间占总运行时间的比例
- 暂停时间:用户线程暂停时间(STW)
- 内存占用:堆区占用内存的大小
吞吐量和暂停时间二者取其一。
吞吐量大则程序性能好,而暂停时间少则程序低延时。因为GC的次数少了必然会导致每次GC耗费的时间更多,而每次GC消耗的时间少了了必然导致GC的次数增加。
标准
:在最大吞吐量量优先的前提下,降低暂停时间,
经典的垃圾回收器
- 串行垃圾回收器:Serial、Serial Old
- 并行垃圾回收器:ParNew、Parallel Scavenge、Paralle Old
- 并发回收器:CMS、G1
垃圾回收器与分代的关系
- 年轻代:Serial、ParNew、Parallel Scavenge
- 老年代:Serial Old、Paralle Old、CMS
- 整堆:G1
组合方式:
红色虚线表示:jdk8不再使用;
绿色虚线表示:jdk14不再使用;
,查看jdk使用的垃圾收集器的参数:-XX:+PrintCommandLineFlags
2.Serial与Serial Old
Serial垃圾收集器
Serial垃圾收集器采用复制算法,串行回收和STW机制执行内存回收。
Serial作为新生代的垃圾收集器。适用于单线程,单CPU 的场景。
优点:简单、高效
Serial Old垃圾收集器
Serial Old垃圾收集器采用标记-压缩算法、串行回收和STW机制执行内存回收。
Serial Old作为老年代的垃圾收集器与Serial垃圾收集器配合使用;此外也作为CMS垃圾收集器的备选方案。
使用参数:-XX:+UseSerialGC 可以设置为使用Serial+Serial Old 的搭配
只有在单核时才使用Serial垃圾收集器,所以现在(尤其是web中)不再使用。
3.ParNew
ParNew是Serial的一个多线程版本,也是只能处理新生代垃圾回收。新生代回收次数频繁,使用并行的效率会更高。
ParNew出了采用并行回收
之外与Serial没有任何区别,也是使用复制算法、STW机制。
ParNew可以与CMS配合使用。
参数设置:
- -XX:+UseParNewGC:使用ParNew垃圾回收器。
- -XX:ParallelGCThreads:限制垃圾回收线程数。
4.Parallel Scavenge
Parallel Scavenge是一个吞吐优先
的垃圾回收器。同样是采用并行回收、复制算法和STW机制`的新生代的垃圾回收器。
与ParNew的主要区别在于:
- Parallel Scavenge垃圾收集器的目标是达到一个
可控制的吞吐量
。 - 自适应调节策略。
高吞吐量可以高效的使用CPU,尽可能快的完成任务,适合后台运算的任务,如:批量处理、订单处理、支付处理、科学计算等。
Parallel Old收集器采用标记-压缩算法,并行回收和STW机制用于老年代的垃圾收集。
相关参数设置:
- -XX:+UseParallelGC:使用ParallelGC作为新生代的垃圾收集器,同时将Parallel Old作为老年代的收集器。
- -XX:+UseParallelOldGC:使用Parallel Old作为老年代的垃圾收集器,同时将Parallel作为新生代的收集器。
- -XX:ParallelGCThreads:限制垃圾回收线程数。
- -XX:MaxGCPauseMillis:设置垃圾收集器最大停顿时间。用于衡量暂停时间指标。
- -XX:GCTimeRatio:设置垃圾收集时间占总时间的比例。用于衡量吞吐量。
- -XX:+UseAdaptiveSizePolicy:设置Parallel垃圾收集器的自适应调节策略。
5.CMS
CMS垃圾回收器是HotSpot第一款真正意义上垃圾收集线程与用户线程同时运行。
CMS更注重低延时
,即缩短暂停时间。适合与用户交互的场景。采用标记-清除算法和STW机制。
只能和Serial或ParNew配合使用。
CMS的垃圾收集步骤如下:
- 初始标记:该阶段仅标记出于GC Roots直接关联的对象,该过程是STW的但是耗时很短;
- 并发标记:该阶段从标记出的直接关联对象开始遍历所有可达对象并标记,整个过程与用户线程并发运行但是耗时较长。
- 重新标记:修正由于并发标记过程中无法确定的对象,该过程为STW的。
- 并发清除:清除标记阶段判断为死亡的对象,释放空间。
注意:由于在并发标记阶段用户线程任然在运行,所以不能等待内存不足时再进行回收(会造成OOM),而是当内存使用率达到一定阈值时就开始回收;但是当在此过程中用户产生大量的对象导致内存不足就会出现”Concurrent Mode Failure“失败,这是就启动后备方案——Serial Old收集器来收集垃圾。
缺点:
- 会产生内存碎片,当使用量增大时会产生Full GC
- 对CPU资源非常敏感,占用一部分线程导致性能下降
- 无法处理浮动垃圾(在并发过程中原来的可达对象变为不可达对象)
相关参数设置:
- -XX:+UseConcMarkSweepGC:老年代使用CMS收集器,同时新生代使用ParNew收集器。
- -XX:CMSInitiatingPermOccupancyFraction=percent:设置CMS垃圾收集器触发的阈值。
- -XX:ParallelCMSThreads:设置垃圾回收线程数。默认:(ParallelCMSThreads+3)/4
6.G1
G1的设计目标
:在延时可控的前提下获得尽可能高的吞吐量。在JDK9之后默认使用。
G1将对空间分成了若干个Region,垃圾回收时对于每个Region会判断哪个Region的回收价值最高(可释放的空间大小),就会对这个Region进行回收。
优势
- 并行与并发兼具
- 分代收集
- 空间整合(Region之间使用复制算法,整体上使用标记-压缩算法)
- 可预测的停顿时间模型(对于每个Region都有其回收价值和回收时间,根据设定的最大延时时间对于若干个Region的回收)
相关G1参数设置:
- -XX:UseG1GC:设置使用G1收集器
- -XX:G1HeapRegionSize:设置每个Region的大小。值是2的幂次,范围是1MB-32MB。
- -XX:MaxGCPauseMillis=time:设置最大停顿时间,不能保证到达,默认值200ms。
- -XX:ParallelGCThread:设置STW工作线程的数,最多为8。
- -XX:ConcGCThread:设置并发标记的线程数。
- -XX:InitiaingHeapOccupancyPercent:设置并发GC周期的java堆占用率阈值,超过就触发GC。
适用场景:适用于服务端,大内存、多处理器;低延时场景。
一个Region可以充当Eden、Survivor或者Old,并且只能同时充当一个角色,但是在整个程序运行期间角色是可以变的。此外还有一个新的区域叫做Humongous,用于存储大对象,且只有在对象大于Region的1.5倍时才能存放在Humongous中。
G1 GC垃圾回收的三个环节:
- 年轻代GC(Young GC):主要使用RSet进行对象的存活判断并使用复制算法将Eden区中存活对象复制到Survivor区中并保证这个Region中是连续的;
- 老年代GC并发标记(Concurrent Marking):并发标记整堆(主要是老年代)中的对象并对各个Region中存活对象比例排序为下个阶段做准备,如果某个区域全部是垃圾则直接回收。同时伴随有YGC。
- 混合回收(Mixed GC):使用复制算法对Eden、Survivor和Old区按照上一步的排序对部分Region进行回收,以实现低延时。
- 可能出现Full GC:没有足够的空间按存放晋升的对象或并发处理完成之前内存空间被用完。
Remembered Set(RSet):
每个Region不可能都是相互独立的,某一个Region中的对象可能会引用其他Region中的对象,从而导致在垃圾回收过程中需要扫描整个对空间以标记哪些对象存活。
为了解决这个问题,使用到了Remembered Set。每个Region都有对应的自己的Set,当每次对Region进行写操作时都会产生一个Write Barrier(写屏障)中断写操作;然后判断该对象是否有引用其他Region中的对象,有则通过CardTable将其记录在Set中;当垃圾收集时,就会将加入Set中对象标记出来,既避免了全盘扫描又保证了不会遗漏对象。
总结:
垃圾收集器 | 分类 | 使用算法 | 特点 | 使用场景 |
---|---|---|---|---|
Serial | 串行运行 | 复制算法 | 响应速度优先 | 单CPU的client模式 |
ParNew | 并行运行 | 复制算法 | 响应速度优先 | 多CPU的Server模式与CMS配合 |
Parallel | 并行运行 | 复制算法 | 吞吐量优先 | 适用于后台运算而不太需要交互的场景 |
Serial Old | 串行运行 | 标记-压缩算法 | 响应速度优先 | 单CPU的client模式 |
Parallel Old | 并行运行 | 标记-压缩算法 | 吞吐量优先 | 适用于后台运算而不太需要交互的场景 |
CMS | 并发运行 | 标记-清除算法 | 响应速度优先 | 适用于B/S业务 |
G1 | 并行、并发运行 | 标记-压缩算法、复制算法 | 响应速度优先 | 面向服务端应用 |
7.GC日志
日志打印参数:
- -verbose:gc:打印GC日志
- -XX:+PrintGCDetails:打印日志详细信息
- -XX:+PrintGCTimeStamps:输出GC的时间戳(时间格式)
- -XX:+PrintGCDateStamps:输出GC的时间戳(日期格式)
- -XX:+PrintHeapAtGC:在进行GC后打印出堆的信息
- -Xloggc:…/…/…/gc.log:日志文件输出路径
垃圾回收日志解析
[GC (Allocation Failure)
[PSYoungGen: 351990K->7424K(360960K)]
1382802K->1182K(1856512K), 0.1194672 secs]
[Times: user=0.14 sys=0.30, real=0.12 secs]
解析:
- GC类型
- Parallel Scavenge垃圾收集器及新生代:收集前当前代的大小->收集后代的大小(新生代总大小)
- 堆已的使用大小->回收后的堆的大小(堆的总大小),GC耗时
- 用户使用时间,系统使用时间,实际使用时间
日志文件分析工具
使用-Xloggc:./logs/gc.log输出GC日志文件
保证了不会遗漏对象。
总结:
垃圾收集器 | 分类 | 使用算法 | 特点 | 使用场景 |
---|---|---|---|---|
Serial | 串行运行 | 复制算法 | 响应速度优先 | 单CPU的client模式 |
ParNew | 并行运行 | 复制算法 | 响应速度优先 | 多CPU的Server模式与CMS配合 |
Parallel | 并行运行 | 复制算法 | 吞吐量优先 | 适用于后台运算而不太需要交互的场景 |
Serial Old | 串行运行 | 标记-压缩算法 | 响应速度优先 | 单CPU的client模式 |
Parallel Old | 并行运行 | 标记-压缩算法 | 吞吐量优先 | 适用于后台运算而不太需要交互的场景 |
CMS | 并发运行 | 标记-清除算法 | 响应速度优先 | 适用于B/S业务 |
G1 | 并行、并发运行 | 标记-压缩算法、复制算法 | 响应速度优先 | 面向服务端应用 |
7.GC日志
日志打印参数:
- -verbose:gc:打印GC日志
- -XX:+PrintGCDetails:打印日志详细信息
- -XX:+PrintGCTimeStamps:输出GC的时间戳(时间格式)
- -XX:+PrintGCDateStamps:输出GC的时间戳(日期格式)
- -XX:+PrintHeapAtGC:在进行GC后打印出堆的信息
- -Xloggc:…/…/…/gc.log:日志文件输出路径
垃圾回收日志解析
[GC (Allocation Failure)
[PSYoungGen: 351990K->7424K(360960K)]
1382802K->1182K(1856512K), 0.1194672 secs]
[Times: user=0.14 sys=0.30, real=0.12 secs]
解析:
- GC类型
- Parallel Scavenge垃圾收集器及新生代:收集前当前代的大小->收集后代的大小(新生代总大小)
- 堆已的使用大小->回收后的堆的大小(堆的总大小),GC耗时
- 用户使用时间,系统使用时间,实际使用时间
日志文件分析工具
使用-Xloggc:./logs/gc.log输出GC日志文件
常用工具:GCEasy等