JVM学习笔记
JVM学习
面试常问
new的过程/创建对象的步骤
- 类加载检查,先去检查是否能在常量池中定位到这个类的符号引用来判断是否已经创建了
- 分配内存(指针碰撞,空闲列表)
- 初始化零值
- 设置对象头:
- 执行init方法
静态变量存储在哪里呢?
- JDK6及之前的版本中,静态变量是存放在方法区中的,也就是永久代。
- JDK7及之后的版本中,静态变量是存放在堆中的Class对象中,脱离了永久代。
组成
- 编译器:不属于Java虚拟机的一部分,负责将源代码文件编译成字节码文件。
- 类加载子系统,负责将字节码文件读取、解析并保存到内存中。其核心就是类加载器。
- 运行时数据区,管理JVM使用到的内存。
- 执行引用,分为解释器 解释执行字节码指令;即时编译器 优化代码执行性能; 垃圾回收器 将不再使用的对象进行回收。
- 本地接口,保存了本地已经编译好的方法,使用C/C++语言实现。
运行时数据区的组成
- 堆,可分为新生代和老年代,新生代可分为Eden,Surviver1,Surviver2
- 方法区:永久区,存储已经被Java虚拟机架子啊的类信息等,jdk1.8之后称为元空间。
- 虚拟机栈:线程私有,内有多个栈帧,方法在执行时会创建栈帧,包含(局部变量表,操作数栈,动态链接,返回地址等)
- 局部变量表:存放方法参数,局部变量
- 操作数栈:记录一个方法在执行过程中,字节码指令向操作数栈进行入栈和出栈的过程。
- 动态链接:字节码中的符号链接,一部分在类加载过程中转化为直接引用(静态解析),还有一部分在运行时转化为直接引用,也就是动态链接。
- 返回地址:当前方法执行过程中,当前方法需要返回的位置。
- 本地方法栈:服务的是Native方法
- PC程序计数器:线程私有
Java虚拟机栈
每一个方法的调用使用一个栈帧来保存,每一个线程都有一个自己的虚拟机栈,生命周期和线程相同主要包括
- 局部变量表:方法执行过程中存放所有的局部变量
- 操作数栈:虚拟机在执行指令过程中用来存放临时数据的一块区域
- 帧数据:主要包括动态链接、方法出口、异常表等内容。
本地方法栈
存储的是native本地方法的栈本地方法(Native Method)是使用非 Java 语言(通常是 C 或 C++)编写的方法。这些方法通过 Java 本地接口(Java Native Interface,JNI)与 Java 代码进行交互。使用本地方法的主要目的是为了实现以下几个功能:
- 与操作系统交互:直接调用操作系统的底层功能。
- 提高性能:在性能关键的部分使用更高效的本地代码。
- 访问硬件:与特定硬件设备进行交互。
- 复用现有库:调用已有的用其他语言编写的库。
堆
用来存放创建出来的对象,栈上的局部变量表中,可以存放对上的引用,静态变量也可以存放堆对象的引用,实现对象在线程之间的共享堆是垃圾回收的最主要部分
类加载器
ClassLoader是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术。去获得二进制字节码信息。然后通过JVM调用JNI也就是本地接口方法区创建对象
类加载器的分类
jdk8以及之前
- Java代码实现的(扩展类加载器,应用程序类加载器)
- Java虚拟机底层代码实现的(启动类加载器)
使用启动加载类器加载器去加载用户的jar包,可以在虚拟机参数那里添加:-Xbootclasspath /a: jar包名
Java中的加载器: 是一个静态内部类,继承自URLClassLoader,通过目录或者指定jar包将字节码文件加载到内存中
双亲委派机制(jdk8以及之前重点)
可以解决的问题:
重复的类:启动类,根据双亲委派机制,如果同一个类出现在三个类加载器中,会由启动类加载器来加载。
String类能覆盖吗? 不能,会由启动类加载器加载在rt.jar包中的String类
类加载器的关系:应用类加载器的父类加载器识扩展类加载器,扩展类加载器没有父类加载器,但是会委派给启动类加载器。
双亲委派机制的作用:
保证类加载器的安全性,避免重复加载。
- 每一个类加载器都有一个父类加载器,在类加载的过程中,每个加载器会先检查是否已经加载了该类,如果已经加载则直接返回否则奖将载请求委派给父类加载器
- 如果所有的父类加载器都无法加载,就由当前加载器尝试加载,也就是说,如果父类加载器的加载路径中没有这个类,就会由他自己加载
如何打破双亲委派机制
- 自定义类加载器并且重写loadClass方法就可以将双亲委派机制的代码去除。Tomcat通过这种方式实现应用之间类隔离,每一个应用会有一个独立的类加载器加载对应的类
- 线程上下文加载器加载类,比如JDBC和JNDI等,JDBC使用DriverManager来管理项目中引入的不同的数据库驱动,DriverManager类位于rt.jar包中,由启动类加载器加载。依赖中的mysql驱动对应的类,由应用程序类加载器来加载,DriverManager属于rt.jar是启动类加载器加载的。而用户jar包中的驱动需要由应用类加载器加载,这就违反了双亲委派机制。
定义服务接口:JDBC定义了java.sql.Driver接口。服务提供者:各个数据库厂商实现java.sql.Driver接口,并在META-INF/services目录下创建文件java.sql.Driver,文件内容为实现类的全限定名。
加载驱动:DriverManager类在静态代码块中通过ServiceLoader加载所有实现java.sql.Driver接口的类,并调用Class.forName方法加载驱动类。定义服务接口:JDBC定义了java.sql.Driver接口。
服务提供者:各个数据库厂商实现java.sql.Driver接口,并在META-INF/services目录下创建文件java.sql.Driver,文件内容为实现类的全限定名。
加载驱动:DriverManager类在静态代码块中通过ServiceLoader加载所有实现java.sql.Driver接口的类,并调用Class.forName方法加载驱动类 - Osgi框架的类加载器,允许同级之间委托进行类的加载
自定义类加载器父类怎么是AppClassLoader呢?
JDK9之后的类加载器
为什么抛弃了拓展类加载器 ?
因为扩展类加载器主要是和加载jre环境下lib下的jar包,需要拓展Java的功能时,需要把jar包能够在ext文件夹下,不安全
JDK9引入的模块化开发取代了他
- 启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中。Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件。但是还是无法获取到
- 扩展类加载器被替换成了平台类加载器,所以继承关系从URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑
双亲委派机制总结
类的生命周期
加载、连接、初始化、使用、卸载
加载
简单概括:找到需要加载的类,并把类的信息加载到JVM中,然后在堆中实例化一个java.lang.Class对象,作为方法区中的这个类的信息的入口
- 加载过程中是类加载器根据类的全限定名通过不同的渠道以二进制流的形式获取字节码信息
- 加载器加载完类之后,Java虚拟机将字节码中的信息保存到方法区中,生成一个InstanceKlass对象,保存类的所有信息,里面还包含实现特定功能比如多态的信息
- Java虚拟机在堆中生成一份与方法去中数据类似的java.lang.Class对象,作用是在Java代码中获取类的信息和存储静态字段的数据,jkd8以后将静态字段放在堆区中。 优点是:对于开发者来说只需要访问堆中的Class对象而不需要访问方法区中的所有信息,能够很好的控制开发者访问数据的范围
连接
- 验证,校验Java字节码文件是都遵循了约束,一般不需要程序员参与
- 为静态变量分配内存并设置初始值,如果使用了final来修饰,就会直接将代码中的值赋给静态变量
- 解析,将符号引用替换为直接引用,符号引用是在字节码中使用序号来进行引用,而直接引用就是使用内存中的地址直接进行访问
初始化
当类被直接引用的时候才会出发类的初始化。类被直接引用的情况有->
new,读取或设置类的静态变量,调用类的静态方法,通过反射来执行前三种,初始化子类时会触发父类的初始化,接口实现类初始化时,会出发直接或间接实现的所有接口的初始化
类的初始化只会运行静态部分,而且优先父类
- 执行静态代码块中的代码,为静态变量赋值
- 初始化阶段会执行字节码中clinit部分的字节码指令
程序中可以直接导致初始化的操作:
- 访问一个类的静态变量或者静态方法,final修饰的并且等号在右侧是常量的话不会触发初始化,因为在连接阶段就已经进行赋值了
- 调用Class.forName(String className)时会进行初始化
- new
- 执行Main方法的当前类
<clinit>
是Java中的一个特殊方法,代表初始化器(class initializer)。这个方法不是由程序员显式编写的,而是由Java虚拟机(JVM)自动生成的。当类被首次加载到JVM时,<clinit>
方法负责执行类变量的静态初始化和静态初始化块的代码clinit
在以下情况不会出现: - 无静态代码块
- 有静态变量声明但是没有赋值语句
- 静态变量的定义使用final字段,会在准备阶段直接进行初始化。
访问父类静态变量,只会初始化父类
new 子类时会先执行父类的clinit方法
数组的创建不会导致数组中元素的类的初始化。
final修饰的变量如果不是常量需要执行指令才能得出结果会执行clinit方法进行初始化
使用
卸载
字节码文件
- 常量池:避免保存重复的内容,节省空间
- 具体的字节码文件分析
int i = 0; i = i ++;0 iconst_0 将0放在操作数栈中 1 istore_1 弹出操作数栈最顶层数据到局部变量1号位 2 iload_1 复制到操作数栈顶 3 iinc 1 by 1 将局部变量1号位的数据 + 1 6 istore_1 弹出,保存在一号位,所以被覆盖了 7 return
组成
基础信息,常量池(保存字符串常量、类或者接口名,主要在字节码指令中使用),字段,方法,属性
运行时数据区
按照线程共享不共享区分:
- 线程不共享
- 程序计数器
- Java虚拟机栈
- 本地方法栈
- 线程共享
- 方法区
- 堆
程序计数器
内存溢出:程序在使用某一块内存区域时,存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限
每个线程只需要存储一个固定长度的内存地址,所以程序计数器是不会发生内存溢出的
程序员无需对程序计数器进行任何处理
虚拟机栈
Java虚拟机使用栈来管理方法调用中的基本数据。每一个方法的调用使用一个栈帧来保存,每个线程都包含一个自己的虚拟机栈
栈帧的组成
- 局部变量表:存放运行中的所有局部变量,包括局部变量表保存的内容有:实例方法的this对象,方法的参数,方法体中声明的局部变量。
- 操作数栈:存放临时数据
- 栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot) ,long和double类型占用两个槽,其他类型占用一个槽。
- 实例方法中的序号为0的位置存放的是this,指的是当前调用方法的对象,运行时会在内存中存放实例对象的地址。
- 为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用。
- 帧数据:包含动态链接、方法出口、异常表的引用
本地方法栈:
存储natice本地方法的栈帧,在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。
堆内存
一般Java程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。
堆空间有三个需要关注的值,used、total、max。used指的是当前已使用的堆内存,total是java虚拟机已经分配的可用堆内存,max是java虚拟机可以分配的最大堆内存。
不是当used = max = total的时候,堆内存就溢出
方法区:
方法区存放基础信息的位置,线程共享的,主要包含三部分:
- 类的元信息,保存所有类的基本信息
- 运行时常量池,保存了字节码文件中常量池内容
- 字符串常量池,保存了字符串常量
元信息
一般称之为InstanceKlass对象。在类的加载阶段完成。其中就包含了类的字段、方法等字节码文件中的内容,同时还保存了运行过程中需要使用的虚方法表(实现多态的基础)等信息。
运行时常量池
字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池。
字符串常量池
字符串的拼接操作会创建一个新的字符串对象,而不是使用字符串常量池中的现有对象。而直接用两个字符串拼接是放在常量池的,不是新建了一个对象
String s1 = new String("abc");
String s2 = "abc";
System.out.println(s1 == s2);
答案是false,因为"abc"是直接放入字符串常量池的,而new 出来的是放在堆内存中,两者所在的位置不同。
1.public class Demo3 {public static void main(String[] args) {String a = "1";String b = "2";String c = "12";String d = "1" + "2";System.out.println(c == d);}
}
答案是true,在编译阶段就把 "1" + "2"连接在一起了
2.
public class Demo2 {public static void main(String[] args) {String a = "1";String b = "2";String c = "12";String d = a + b;System.out.println(c == d);}
}
答案是false
jdk8之后运行时常量池放在元空间,而字符串常量池还在堆中。
String.intern()方法是可以手动将字符串放入字符串常量池中
直接内存
直接内存不属于Java运行时的内存区域,NIO机制中使用直接内存来解决
- Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用。
- IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中。
现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路。
执行引擎
本地接口
垃圾收集器GC
GC (Garbage Collection),堆是垃圾回收最主要的区域,所以也被乘坐GC堆。
如何手动触发垃圾回收:使用System.gc()
,但是这个方法不会立即进行回收,只是向虚拟机发送一个垃圾回收的请求。
不由GC进行回收的部分
线程不共享的部分不需要GC进行回收,因为随着线程的销毁,对应的方法的栈帧就会自动弹出栈并且释放掉对应的内存。
进行回收的部分
方法区
主要回收不再使用的类,需要满足以下条件
- 这个类所有的实例对象都被回收,在队中不存在该类的实例对象以及子类对象
- 加载该类的类加载器已经被回收,类加载器的任务完成之后引用被去除后就会被回收
- java.lang.Class对象没有在任何地方被引用
对象
Java中的对象能否被回收是根据对象是否被引用来决定的。如果对象被引用就不允许被回收主要的算法:
引用计数法
为每个对象维护一个指针,对象被引用时+1,取消引用时-d。缺点是:
- 每次引用都要维护计数器,会影响系统的性能
- 存在循环引用问题,A引用B,B引用A就无法回收了
可达性分析法
可达性分析法将对象分为:垃圾回收的根对象 (GC Root)和普通对象,对象之间存在引用关系。
可达性分析就是如果某个对象到GC Root是可达的,就不能被回收。
哪些对象被称之为GC Root对象呢?
- 线程Thread对象,引用线程栈帧中的方法参数、局部变量等。
- 系统类加载器加载的java.lang.Class对象,引用类中的静态变量。
- 监视器对象,用来保存同步锁synchronized关键字持有的对象。
- 本地方法调用时使用的全局对象。
常见的引用对象
可达性算法中描述的对象引用,一般指的是强引用,Java中还设计了几种其他引用方式:
- 软引用
- 弱引用
- 虚引用
- 终结器引用
软引用
如果一个对象只有软引用关联到他时,程序内存不足时回将其中的数据及逆行回收,软引用常用于缓存中
好处就是用作缓存快速从内存中读取,即使被释放了也可以重新获取,减少内存溢出的可能性
软引用对象本身,也需要被强引用,否则软引用对象也会被回收掉。
使用案例:
/*** 软引用案例2 - 基本使用*/
public class SoftReferenceDemo2 {public static void main(String[] args) throws IOException {byte[] bytes = new byte[1024 * 1024 * 100];SoftReference<byte[]> softReference = new SoftReference<byte[]>(bytes);bytes = null;System.out.println(softReference.get());byte[] bytes2 = new byte[1024 * 1024 * 100];System.out.println(softReference.get());
//
// byte[] bytes3 = new byte[1024 * 1024 * 100];
// softReference = null;
// System.gc();
//
// System.in.read();}
}
如果软引用对象中的数据已经被回收了,那么这个对象本身也可以被回收了SoftReference提供了一套队列机制:
- 软引用创建时,通过构造器传入引用队列
- 在软引用中包含的对象被回收时,该软引用对象会被放入引用队列
- 通过代码遍历引用队列,将SoftReference的强引用删除
/*** 软引用案例3 - 引用队列使用*/ public class SoftReferenceDemo3 {public static void main(String[] args) throws IOException {ArrayList<SoftReference> softReferences = new ArrayList<>();ReferenceQueue<byte[]> queues = new ReferenceQueue<byte[]>();for (int i = 0; i < 10; i++) {byte[] bytes = new byte[1024 * 1024 * 100];SoftReference studentRef = new SoftReference<byte[]>(bytes,queues);softReferences.add(studentRef);}SoftReference<byte[]> ref = null;int count = 0;while ((ref = (SoftReference<byte[]>) queues.poll()) != null) {count++;}System.out.println(count);} }
弱引用
弱引用整体与软引用基本一致,但是弱引用不管内存够不够都会直接被回收
package chapter04.weak;import java.io.IOException;
import java.lang.ref.WeakReference;/*** 弱引用案例 - 基本使用*/
public class WeakReferenceDemo2 {public static void main(String[] args) throws IOException {byte[] bytes = new byte[1024 * 1024 * 100];WeakReference<byte[]> weakReference = new WeakReference<byte[]>(bytes);bytes = null;System.out.println(weakReference.get());System.gc();System.out.println(weakReference.get());}
}
虚引用和终结器引用
这两种引用在常规开发中是不会使用的。
- 虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。
- 终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做。
package chapter04.finalreference;/*** 终结器引用案例*/ public class FinalizeReferenceDemo {public static FinalizeReferenceDemo reference = null;public void alive() {System.out.println("当前对象还存活");}@Overrideprotected void finalize() throws Throwable {try{System.out.println("finalize()执行了...");//设置强引用自救reference = this;}finally {super.finalize();}}public static void main(String[] args) throws Throwable {reference = new FinalizeReferenceDemo();test();test();}private static void test() throws InterruptedException {reference = null;//回收对象System.gc();//执行finalize方法的优先级比较低,休眠500ms等待一下Thread.sleep(500);if (reference != null) {reference.alive();} else {System.out.println("对象已被回收");}} }
垃圾回收算法
- 找到内存中存活的对象
- 释放不再存活对象的内存,使得程序能再次利用这部分空间
垃圾回收算法的历史和分类
1960年John McCarthy发布了第一个GC算法:标记-清除算法。1963年Marvin L. Minsky 发布了复制算法。
本质上后续所有的垃圾回收算法,都是在上述两种算法的基础上优化而来。
标记清除算法
GCRoot包含的对象:
• 虚拟机栈(栈帧中的局部变量表)中引⽤的对象
• 本地⽅法栈(Native ⽅法)中引⽤的对象
• ⽅法区中类静态属性引⽤的对象
• ⽅法区中常量引⽤的对象
• 所有被同步锁持有的对象
• JNI(Java Native Interface)引⽤的对象
标记清除算法的核心思想分为两个阶段:
- 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。从GC Root对象开始扫描,将对象A、B、C在引用链上的对象标记出来
- 清除阶段,从内存中删除没有被标记也就是非存活对象。将没有标记的对象清理掉,所以对象D就被清理掉了。
缺点: - 碎片化问题由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配
- 分配速度慢。由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。 我们需要用一个链表来维护,哪些空间可以分配对象,很有可能需要遍历这个链表到最后,才能发现这块空间足够我们去创建一个对象。如下图,遍历到最后才发现有足够的空间分配3个字节的对象了。如果链表很长,遍历也会花费较长的时间。
复制算法
- 准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。
- 在垃圾回收GC阶段,将From中存活对象复制到To空间。
- 将两块空间的From和To名字互换。下次依然在From空间上创建对象。
优点:
- 吞吐量高,复制算法只需要遍历一次存活对象复制到To空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法,因为标记清除算法不需要进行对象的移动
- 不会发生碎片化,复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
缺点:
内存使用效率低,每次只能让一半的内存空间来为创建对象使用。
标记整理算法
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。
核心思想分为两个阶段:
- 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。
优点:
- 内存使用效率高,整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
- 不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
缺点:
整理阶段的效率不高,整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two-Finger、表格算法、ImmixGC等高效的整理算法优化此阶段的性能
分代垃圾回收算法
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。
分代垃圾回收将整个内存区域划分为年轻代和老年代:
- 分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。
- 随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为Minor GC或者Young GC。Minor GC会把需要eden中和From需要回收的对象回收,把没有回收的对象放入To区。
- 接下来,S0会变成To区,S1变成From区。当eden区满时再往里放入对象,依然会发生Minor GC。
- 如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。
- 当老年代中空间不足,无法放入新的对象时,先尝试minor gc如果还是不足,就会触发Full GC,Full GC会对整个堆进行垃圾回收。如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。
为什么分代GC算法要把堆分成年轻代和老年代?首先我们要知道堆内存中对象的特性:
- 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。
- 老年代中会存放长期存活的对象,比如Spring的大部分bean对象,在程序启动之后就不会被回收了。
- 在虚拟机的默认设置中,新生代大小要远小于老年代的大小。
分代GC算法将堆分成年轻代和老年代主要原因有:
- 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
- 新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
- 分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW时间就会减少。
垃圾回收器 重点
一些好用JVM分析工具
[Arthas](Arthas Install | arthas (aliyun.com))
阿里开发的工具
内存分配
- 优先分配在Eden区,如果Eden区没有足够的空间进行分配时,虚拟机会进行一次MinorGC,而那些无需回收的存货对象会进入Survivor的From区,再不满足就进入Old区
- 大对象直接进入老年代,避免在Eden区和两个Survivor区之间发生大量的内存拷贝
- 长期存货的对象进入老年代,虚拟机为每一个对象定义一个年龄计数器,经过一次MinorCG就会进入Survivor区,此后每经历一次都会年龄+1,到达阈值,对象进入老年区。
Full GC和Minor GC和内存回收算法
大多数情况下,对象在新生代中Eden区分配,当Eden区没有足够的内存进行分配时,回发起一次Minor GC。经过第一次Minor GC仍能够存货,并且能够被Survior容器容纳的话,会被移动到Survivor空间。并且将对象年龄设置为1,之后对象每熬过一次MinorGC,年龄就增加1岁,当它的年龄达到一定程度时,就会被晋级到老年代中。部分垃圾回收器会将大对象直接放入就老代。
Minor GC/ Young GC
只对新生代进行垃圾收集
Major GC / Old GC
只对老年代进行GC,有时候也可以代指Full GC
Full GC
回收整个Java堆和方法区
内存调优
内存泄漏(memory leak):Java中的如果不再使用一个对象,但是该对象依然在CG ROOT的引用链上,这个对象就不会被回收。
绝大数情况都是由堆内存泄露引起的
常见由:
- 没有及时删除缓存数据
- 分布式任务调度系统等进行任务调度任务结束中出现了内存泄漏。
代码中的内存泄漏
equals和hashCode,不正确使用会导致泄漏
- 不一致的
equals()
和hashCode()
实现:- 如果两个对象根据
equals()
方法被认为是相等的,那么它们的hashCode()
值也必须相等。如果这个规则被违反,可能会导致哈希表中的对象无法正确地被访问和删除,从而导致内存泄漏。
- 如果两个对象根据
- 对象无法被正确移除:
- 在使用哈希表时,如果对象的
hashCode()
值在插入后发生变化,可能会导致对象无法被正确移除,因为哈希表依赖于hashCode()
值来定位对象。
在定义新类时没有重写正确的equals()和hashCode()方法。在使用HashMap的场景下,如果使用这个类对象作为key,HashMap在判断key是否已经存在时会使用这些方法,如果重写方式不正确,会导致相同的数据被保存多份。
正常情况:
- 在使用哈希表时,如果对象的
- 以JDK8为例,首先调用hash方法计算key的哈希值,hash方法中会使用到key的hashcode方法。根据hash方法的结果决定存放的数组中位置。
- 如果没有元素,直接放入。如果有元素,先判断key是否相等,会用到equals方法,如果key相等,直接替换value;key不相等,走链表或者红黑树查找逻辑,其中也会使用equals比对是否相同。
异常情况: - hashCode方法不对,导致相同id的学生对象计算出来的hash值不同被放在不同的槽中
- equals方法不对,导致即使id相同,也会被认为是不同的对象
下列代码会重复添加这个对象,导致内存溢出,因为不是相同的对象实例
class Student {Integer id;String name;public void setId(Integer id) {this.id = id;}public void setName(String name) {this.name = name;}public Integer getId() {return id;}public String getName() {return name;}}
class Main {public static long count = 0;public static Map<Student,Long> map = new HashMap<>();public static void main(String[] args) throws InterruptedException {while (true){Student student = new Student();student.setId(1);student.setName("张三");map.put(student,1L);}}}
}
解决方案:
- 在定义新实体时,始终重写equals()和hashCode()方法。
- 重写时一定要确定使用了唯一标识去区分不同的对象,比如用户的id等。
- hashmap使用时尽量使用编号id等数据作为key,不要将整个实体类对象作为key存放。
ThreadLocal的使用
线程池中的线程不被回收导致的ThreadLocal内存泄漏
如果仅仅使用手动创建的线程,就算没有调用ThreadLocal的remove方法清理数据,也不会产生内存泄漏。因为当线程被回收时,ThreadLocal也同样被回收。但是如果使用线程池就不一定了。
解决方案:
线程方法执行完,一定要调用ThreadLocal中的remove方法清理对象。
内部类引用外部类
非静态的内部类和匿名内部类的错误使用导致内存泄漏
- 非静态的内部类默认会持有外部类 ,尽管代码上不再使用外部类,所以如果有地方引用了这个非静态内部类,会导致外部类也被引用,垃圾回收时无法回收这个外部类。
- 匿名内部类对象如果在非静态方法中被创建,会持有调用者对象,垃圾回收时无法回收调用者。
解决方案:
- 使用静态内部类从而不持有外部对象
- 使用静态方法,避免匿名内部类持有调用者对象
String的intern方法
由于JDK6中的字符串常量池位于永久代,intern被大量调用并保存产生的内存泄漏
通过静态字段保存对象
大量的数据在静态变量中被引用,但是不再使用,成为了内存泄漏
问题:
如果大量的数据在静态变量中被长期引用,数据就不会被释放,如果这些数据不再使用,就成为了内存泄漏。
解决方案:
1、尽量减少将对象长时间的保存在静态变量中,如果不再使用,必须将对象删除(比如在集合中)或者将静态变量设置为null。
2、使用单例模式时,尽量使用懒加载,而不是立即加载。
package com.itheima.jvmoptimize.leakdemo.demo7;import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;@Lazy //懒加载
@Component
public class TestLazy {private byte[] bytes = new byte[1024 * 1024 * 1024];
}
资源没有正常关闭
由于资源没有调用close方法正常关闭,导致的内存溢出
连接和流这些资源会占用内存,如果使用完之后没有关闭,这部分内存不一定会出现内存泄漏,但是会导致close方法不被执行。
解决方案:
- 为了防止出现这类的资源对象泄漏问题,必须在finally块中关闭不再使用的资源。
- 从 Java 7 开始,使用try-with-resources语法可以用于自动关闭资源。
try (BufferedReader br = new BufferedReader(new FileReader("file1.txt"));BufferedReader br2 = new BufferedReader(new FileReader("file2.txt")) ) {} catch (IOException e) {e.printStackTrace(); }
并发请求问题
GC调优
GC调优是对垃圾回收进行调优,GC调优的主要目标是避免由垃圾回收引起程序性能下降可以进行调优的内容:
- 通用JVM参数的设置
- 特定垃圾回收器的JVM参数的设置
- 解决由频繁FULLGC引起的程序性能问题
调优指标:
垃圾回收的吞吐量
- 吞吐量,一段时间内程序需要完成的业务数量。
保证高吞吐量的常规手段有两条:
1、优化业务执行性能,减少单次业务的执行时间
2、优化垃圾回收吞吐量
垃圾回收吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高,允许更多的CPU时间去处理用户的业务,相应的业务吞吐量也就越高。
延迟
1延迟指的是从用户发起一个请求到收到响应这其中经历的时间。比如企业中对于延迟的要求可能会是这样的:所有的请求必须在5秒内返回给用户结果
延迟 = GC延迟 + 业务执行时间,所以如果GC时间过长,会影响到用户的使用。
内存使用量
内存使用量指的是Java应用占用系统内存的最大值,一般通过Jvm参数调整,在满足上述两个指标的前提下,这个值越小越好。
调优工具
jstat工具
Jstat工具是JDK自带的一款监控工具,可以提供各种垃圾回收、类加载、编译信息
等不同的数据。使用方法为:jstat -gc 进程ID 每次统计的间隔(毫秒) 统计次数
C代表Capacity容量,U代表Used使用量
S – 幸存者区,E – 伊甸园区,O – 老年代,M – 元空间
YGC、YGT:年轻代GC次数和GC耗时(单位:秒)
FGC、FGCT:Full GC次数和Full GC耗时
GCT:GC总耗时
Visualvm插件
Prometheus + Grafana
GC日志
分析GC日志 - GCViewer
GraalVM
GraalVM是Oracle官方推出的一款高性能JDK,使用它享受比OpenJDK或者OracleJDK更好的性能。两种模式:
- JIT( Just-In-Time )模式 ,即时编译模式,在运行时将热点代码编译为本地机器码,以提高执行效率。
- AOT(Ahead-Of-Time)模式 ,提前编译模式