目录
一、JVM内存结构
一、JVM内存结构
内存划分
JVM内存共分为堆、虚拟机栈,方法区,本地方法栈、程序计数器(寄存器)。
- 堆:被所有线程共享的一块内存区域,在虚拟机启动的时候创建,用于存放对象实例。
- 虚拟机栈:是线程私有的。每个方法在执行的时候都会创建一个栈帧,栈帧存储了局部变量,操作数栈,动态链接,方法返回地址。
-
局部变量表:
局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型。局部变量表所需的内存空间在编译期确定,当进入一个方法时,方法在栈帧中所需要分配的局部变量控件是完全确定的,不可动态改变大小。
-
操作数栈:
后进先出LIFO,最大深度由编译期确定。栈帧刚建立使,操作数栈为空,执行方法操作时,操作数栈用于存放JVM从局部变量表复制的常量或者变量,提供提取,及结果入栈,也用于存放调用方法需要的参数及接受方法返回的结果。
操作数栈可以存放一个jvm中定义的任意数据类型的值。
在任意时刻,操作数栈都一个固定的栈深度,基本类型除了long、double占用两个深度,其它占用一个深度
-
动态连接:
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
-
方法返回地址:
当一个方法被执行后,有两种方式退出该方法:执行引擎遇到了任意一个方法返回的字节码指令(lreturn、freturn、dreturn以及areturn)或遇到了异常,并且该异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。
-
方法区:线程共享的一块内存区域,用于存储已经被虚拟机加载的类信息,常量,静态变量等。
-
本地方法栈:线程私有的,与虚拟机栈类似,主要为虚拟机使用到的Native方法服务。
-
程序计数器:线程私有的,程序计数器指当前正在执行的字节码的行号。如果是Native方法,则为空。
对象创建
1、类加载检查: 虚拟机遇到一条 new 指令时,首先会去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
2、分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式:
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。
3、初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
4、设置对象头: 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、哈希值、 gc分代年龄 、锁状态标志、 线程持有的锁。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
5、执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init>
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
常量池
Java 基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean;这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。
两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。String也实现了常量池。比如:
public static void main(String[] args) {
String a="123";
String b="123";
String c=new String("123");
System.out.println(a==b); //true
System.out.println(a.equals(c));//true
}
因为a和b都是从常量池内取值,所以这俩个值相等,那a和c不应该返回true啊,因为这俩对象在堆中的引用地址一定不同啊。这个时候需要一个新的知识点,==和equal的区别
基础数据类型(Byte,Short,Integer,Long,Character,Boolean),== 与 equal 都是作用于比较对象内容(堆)是否相同。
引用对象类型, == 与 equal 都是作用于比较对象内存地址(栈)是否相同。
那既然是这样a与c更应该是false。所有的类都继承Object类,如果不重写equals(),默认执行的是Object的equals()方法
public boolean equals(Object obj) {
return (this == obj);
}
String 类重写了equals()方法,所以a,c是true。
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
对于基本数据类型,(Byte,Short,Integer,Long,Character,Boolean)这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。而Float和Double则没有。所以创建对象后的引用地址必然不同。
public static void main(String[] args) {
Integer a=300;
Integer b=300;
Integer c=30;
Integer d=30;
System.out.println(a==b);//false
System.out.println(c==d);//true
}
总结:相同内容的对象地址不一定相同,但相同地址的对象内容一定相同。
二、类加载
类加载过程
当程序主动使用某个类时,如果该类还没有被加载到内存,则JVM会通过加载、连接、初始化来对这个类进行初始化。
类加载生命周期
加载
加载,是指Java虚拟机查找字节流(查找.class文件),并且根据字节流创建java.lang.Class对象的过程。这个过程,将类的.class文件中的二进制数据读入内存,放在运行时区域的方法区内。然后在堆中创建java.lang.Class对象,用来封装类在方法区的数据结构。
类加载阶段:
(1)Java虚拟机将.class文件读入内存,并为之创建一个Class对象。
(2)任何类被使用时系统都会为其创建一个且仅有一个Class对象。
(3)这个Class对象描述了这个类创建出来的对象的所有信息,比如有哪些构造方法,都有哪些成员方法,都有哪些成员变量等。
验证
验证阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。整体来看,验证阶段大致分为4个验证动作。
1、文件格式验证
第一阶段是验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。比如是否以魔数开头,(为了方便虚拟机识别一个文件是否是class类型的文件,SUN公司规定每个class文件都必须以一个word(四个字节)作为开始,这个数字就是魔数。主、次版本号是否在当前虚拟机处理范围内;常量池的常量数据类型是否被支持。
2、元数据验证
元数据验证是对字节码描述信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。这个阶段可能的验证点:
a.是否有父类;
b.是否继承了不被允许继承的类;
c.如果该类不是抽象类,是否实现了其父类或接口要求实现的所有方法;
3、字节码验证
字节码验证的主要目的是通过数据流和控制流分析,确定程序语义的合法性和逻辑性。该阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事情。这个阶段可能的验证点:
a.保证任何时候操作数栈的数据类型与指令代码序列的一致性;
b.跳转指令不会跳转到方法体以外的字节码指令上;
4、符号引用验证
符号引用验证的主要目的是保证解析动作能正常执行,如果无法通过符号引用验证,则会抛出异常。这个阶段可能的验证点:
a.符号引用的类、字段、方法的访问性(public、private等)是否可被当前类访问;
b.指定类是否存在符合方法的字段描述符;
准备
为静态变量分配内存,并将其初始化为默认值。
注意:
public static int value = 1;在准备阶段的初始值是 0而不是1,而把value赋值的putstatic指令将在初始化阶段才会被执行。
特殊情况:
public static final int value = 1;//此时准备value赋值为1。
解析
解析阶段是虚拟机将常量池内的符号引用替换成直接引用的过程。直接引用是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。
初始化
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。为初始化变量赋值,执行类构造器等。
创建对象
- new关键字创建 Class a=new A();此方法会调用构造函数。
- 通过反射的实体类.newInstance(), Class.forName("com.xiaojie.entity.User") 全限定类名。此方法会调用无参构造函数。
- constructor.newInstance(); 此方法会调用构造函数。
- clone()克隆方法。此方法不会调用构造函数。浅克隆是指拷贝对象时仅仅拷贝对象本身(包括对象中的基本变量),而不拷贝对象包含的引用指向的对象。深克隆不仅拷贝对象本身,而且拷贝对象包含的引用指向的所有对象。
- 使用反序列化,此方法可以进行深克隆,也不会调用构造函数。
package com.xiaojie.entity;
/**
* @Description:
* @author: xiaojie
* @date: 2021.09.22
*/
public class User implements Cloneable{
private Long id;
private String name;
private Integer age;
public void setId(Long id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
public void setAge(Integer age) {
this.age = age;
}
public String getName() {
return name;
}
public Integer getAge() {
return age;
}
public Long getId() {
return id;
}
public User(Long id, String name, Integer age) {
this.id = id;
this.name = name;
this.age = age;
System.out.println("我是有参构造函数。。。。。。");
}
public User() {
System.out.println("我是无参的构造函数。。。。。");
}
@Override
public Object clone() throws CloneNotSupportedException {
//如果要进行深克隆,需要在此处重写clone()方法,对引用型对象进行克隆
return super.clone();
}
}
public static void main(String[] args) throws InstantiationException, IllegalAccessException, ClassNotFoundException, NoSuchMethodException, InvocationTargetException, CloneNotSupportedException, IOException {
//1、new 关键字
User user=new User();//调用无参构造函数
//2、通过反射的实体类.newInstance(), Class.forName("com.xiaojie.entity.User") 全限定类名
User user1 = User.class.newInstance();//调用无参构造函数
Class<?> aClass = Class.forName("com.xiaojie.entity.User");
User user2 = (User) aClass.newInstance();//调用无参构造函数
//3、constructor.newInstance();
Constructor<User> constructor = User.class.getConstructor();
User user3 = constructor.newInstance(); //调用无参构造函数
Constructor<User> constructor1 =User.class.getConstructor(Long.class,String.class,Integer.class);
User user5 = constructor1.newInstance(1L, "tom", 18);//调用有参构造函数
//4、使用clone方法 不会调用构造器
User user4= (User) user5.clone();
System.out.println(user4);//com.xiaojie.entity.User@f6f4d33
System.out.println(user5);//com.xiaojie.entity.User@23fc625e 可见复制后的对象并不相等,但是对象的属性值是一样的,
System.out.println(user5.getName()==user4.getName()); //true
//浅克隆是指拷贝对象时仅仅拷贝对象本身(包括对象中的基本变量),而不拷贝对象包含的引用指向的对象。
//深克隆不仅拷贝对象本身,而且拷贝对象包含的引用指向的所有对象。
//5、使反序列化,反序列化可以进行深克隆 不会调用构造器
ObjectInputStream in = new ObjectInputStream(new FileInputStream(""));
User user6 = (User) in.readObject();
}
类加载器
类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。一旦一个类被加载到JVM中,同一个类就不会被再次载入了。
JVM预定义的有三种类加载器
根类加载器(Bootstrap ClassLoader):或者叫启动类加载器,它用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
扩展类加载器(Extension ClassLoader):它负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。由Java语言实现,父类加载器为null。
系统类加载器(Application ClassLoader):被称为系统(也称为应用程序)类加载器,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为ExtClassLoader。
类加载器加载类的大致步骤
JVM的类加载机制主要有如下3种
全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
双亲委派:所谓的双亲委派,则是先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。
缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为很么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。
- 在加载之前会判断缓存区是否存在该类对象,如果存在则直接返回相应的对象。
- 如果不存在,则判断该类加载器是否有父类加载器,或者自己是一个父类加载器,根加载器。
- 如果有父类加载器,则委托父类加载器去加载(如果父类有父类依次递归)。如果父类加载器没有找到该类,则自己去加载该类,加载成功返回,加载失败,抛出ClassNotFoundExcepton的异常。
- 如果是根类加载器则利用根类加载器加载对应的对象。加载成功返回,加载失败,抛出ClassNotFoundExcepton的异常。
双亲委派模式
双亲委派机制,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,这就是双亲委派模式。
双亲委派模式的好处:采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。再一个是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
如何破坏双亲委派模式
双亲委派代码
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 检查该类是否已经加载过。如果加载过就直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
//没加载过,调用父类加载器去加载,递归调用
c = parent.loadClass(name, false);
} else {
//没有父类就启动启动类去加载,这是个native方法
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//如果没有找到抛出异常
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
//如果还没找到,则自己去加载该类
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
破坏双亲委派有两种方式
1、自定义类加载器,重写findClass();
package com.xiaojie.classloader;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.WritableByteChannel;
/**
* @Description: 自定义类加载器
* 使用场景
* (1)加密:Java代码可以轻易的被反编译,如果你需要把自己的代码进行加密以防止反编译,
* 可以先将编译后的代码用某种加密算法加密,类加密后就不能再用Java的ClassLoader去加载类了,
* 这时就需要自定义ClassLoader在加载类的时候先解密类,然后再加载。
*
* (2)从非标准的来源加载代码:如果你的字节码是放在数据库、甚至是在云端,
* 就可以自定义类加载器,从指定的来源加载类。
*
* (3)以上两种情况在实际中的综合运用:比如你的应用需要通过网络来传输 Java 类的字节码,
* 为了安全性,这些字节码经过了加密处理。这个时候你就需要自定义类加载器来从某个网络地址上读取
* 加密后的字节代码,接着进行解密和验证,最后定义出在Java虚拟机中运行的类。
* @author: xiaojie
* @date: 2021.09.23
*/
public class MyClassLoader extends ClassLoader {
public MyClassLoader() {
}
public MyClassLoader(ClassLoader parent) {
super(parent);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
File file = new File("D:/People.class");
try {
byte[] bytes = getClassBytes(file);
//defineClass方法可以把二进制流字节组成的文件转换为一个java.lang.Class
Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
return c;
} catch (Exception e) {
e.printStackTrace();
}
return super.findClass(name);
}
private byte[] getClassBytes(File file) throws Exception {
// 这里要读入.class的字节,因此要使用字节流
FileInputStream fis = new FileInputStream(file);
FileChannel fc = fis.getChannel();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
WritableByteChannel wbc = Channels.newChannel(baos);
ByteBuffer by = ByteBuffer.allocate(1024);
while (true) {
int i = fc.read(by);
if (i == 0 || i == -1)
break;
by.flip();
wbc.write(by);
by.clear();
}
fis.close();
return baos.toByteArray();
}
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
MyClassLoader mcl = new MyClassLoader();
Class<?> clazz = Class.forName("People", true, mcl);
Object obj = clazz.newInstance();
System.out.println(obj);
System.out.println("使用的类加载器是:" + obj.getClass().getClassLoader());
}
}
1、
2、使用线程上下文类加载器。典型案例如JDBC连接,通过应用程序类加载器加载。 ClassLoader loader = Thread.currentThread().getContextClassLoader();
三、垃圾回收机制
什么是垃圾回收
垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露。有效的使用可以使用的内存,对内存堆中不可达的对象进行清除和回收。垃圾回收是自动进行回收的,不能人为控制。程序员唯一能做的就是通过调用System.gc() 方法来"建议"执行垃圾收集器,但其是否可以执行,什么时候执行却都是不可知的。
MinorGC和MajorGC
新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
老年代 GC(Major GC / Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 ParallelScavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程) 。MajorGC 的速度一般会比 Minor GC 慢 10倍以上。
垃圾判断算法
引用计数器法
引用计数法就是给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1。任何时刻计数器值为0的对象就是不可能再被使用的。但是这种方法不能判断对象相互引用的这种情况。
根搜索算法
根搜索算法的基本思路就是通过一系列名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可达的。
GC ROOTS主要回收的区域
(1). 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。
(2). 方法区中的类静态属性引用的对象。
(3). 方法区中常量引用的对象。
(4). 本地方法栈中JNI(Native方法)引用的对象。
垃圾回收算法
标记-清除
标记-清除包含两部分,标记和清除。一部分标记出可达的对象(有的人认为是标记不可达的对象),然后清除掉不可达的对象。
这种算法的缺点是容易产生不连续的空间碎片,而且标记和清除的效率都不是很高。这种算法适合老年代的对象回收。
复制算法
内存会被分为两部分From区和To区。每次只是使用from区,to区则空闲着。当from区内存不够了,开始执行GC操作,这个时候,会把from区存活的对象拷贝到to区,然后直接把from区进行内存清理。
这种算法的虽然避免了标记-清除碎片化的问题,但是如果回收对象较多较大需要花费更长的时间,而且总会有一部分空间是空闲的,浪费内存空间。这种算法适用于新生代的对象 。
标记-整理(标记-压缩)
标记整理和标记清除算法比较相同,也经过标记阶段,然后把可达对象移动到一端,对不可达的对象进行删除。
这种算法也解决了空间碎片化的问题,但是移动对象,需要修改对象的引用地址,而且标记,整理效率也不高。这种算法适合老年代的对象回收。
分代算法
这种算法,根据对象的存活周期的不同将内存划分成几块,新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。新生代对象朝生夕死,对象数量多,只要重点扫描这个区域,那么就可以大大提高垃圾收集的效率。另外老年代对象存储久,无需经常扫描老年代,避免扫描导致的开销。
新生代使用复制算法,因为新生代中的对象一般都是朝生夕死的,存活对象的数量并不多,这样使用复制算法进行拷贝时效率比较高。jvm将堆内存划分为新生代与老年代,又将新生代划分为Eden与2块Survivor Space,然后在Eden –>Survivor Space 以及From Survivor Space 与To Survivor Space 之间实行复制算法。
堆空间中新生代和老年代的默认比例是1:2(可以通过参数 –XX:NewRatio)来设定,在新生代中Eden:From:To=8:1:1 (通过参数 –XX:SurvivorRatio )来设定。
复制算法的过程
- 当Eden区满的时候,会触发第一次MinorGC,把还活着的对象拷贝到Survivor From区。这个时候存活的对象就1岁了。当eden区再次执行MinorGC,就会扫描Eden和From区,把存活的对象复制到To区,然后清空Eden和From区。
- 当Eden区再次满了之后,再次触发MinorGC,就会扫描Eden和To(新的From区)区,然后将存活的对象复制到From区(新的To区),然后清空Eden和To区。
- 这样依次往复,在From和To区之间复制来复制去,每熬过一次MinorGC的对象就长大一岁,当对象年满15岁之后,依然存活,则会进入老年代。
- 可以通过参数设置-XX:MaxTenuringThreshold=15 默认也是15次
注意:这种情况不考虑,破格直接进入老年代的情况。
老年代使用标记清除或者标记整理
老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须“标记-清除-压缩”算法进行回收。
新生代如何进入老年代
- 创建大对象直接进入老年代 -XX:PretenureSizeThreshold=1M 只对Serial及ParNew收集器管用。
- 新生代采用的是复制收集算法,S0和S1始终只是用其中一块内存区,当出现MinorGC后大部分对象仍然存活的话,就需要老年代进行空间分配担保,把survior区无法容纳的对象直接晋升到老年代。
- 长期存活的对象>15岁
- 当 Survivor 空间中相同年龄(比如10)所有对象的大小总和大于 Survivor 空间的一半,年龄大于或等于该年龄(10)的对象就可以直接进入老年代,而不需要达到MaxTenuringThreshold的分代年龄。
如何触发FullGC
- System.gc()方法的调用(大多数的情况下都会进行fullGC,但不能百分之百保证)。
- 当老年代没有足够空间存放对象时(认为达到92%这个数值仅供参考),会触发一次FullGC。
- 空间分配担保时,如果剩余空间不足以盛放新生代的对象,这时要进行一次FullGC
- 如果元空间区域的内存达到了所设定的阈值-XX:MetaspaceSize=,触发FullGC。
内存溢出和内存泄漏
内存溢出(out of memory),是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如系统就分给你10M的空间,你要存放20M的东西,这样就会导致内存溢出。
产生原因:
1.内存中加载的数据量过于庞大,如一次从数据库取出过多数据;
2.集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
3.代码中存在死循环或循环产生过多重复的对象实体;
4.使用的第三方软件中的BUG;
5.启动参数内存值设定的过小
内存溢出解决方式就是增大jvm的内存
-Xmx3550m -Xms3550m 设置最大内存和初始化内存,两者尽量一致,避免每次垃圾回收完成后JVM重新分配内存。
内存泄露(memory leak),是指程序在申请内存后,无法释放已申请的内存空间。比如一个对象占用了10M的空间,但是它使用完了,一直不释放。如果一次内存泄漏可以容忍,但是有很多的内存泄漏,不管有多少的内存迟早会被占用光,而导致的后果就是,内存溢出。
产生原因:
内存泄露的本质原因是因为代码问题
- 不使用的对象不能被垃圾回收机制回收。
- 使用完的资源记得关闭,比如io,数据库等close()。
四、垃圾收集器
垃圾回收器有Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1、ZGC(jdk11之后)。按照新生代和老年代来分,负责新生代的主要是 Serial、ParNew、Parallel Scavenge,负责老年代回收的是Serial Old、Parallel Old、CMS,而G1回收器可以对整个堆进行垃圾回收。
Serial垃圾收集器
特点:单线程、简单高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。使用新生代复制算法。
应用场景:适用于Client模式下的虚拟机。
运行示意图
ParNew垃圾收集器
和Serial完全一致,除了在收集器使用多线程外。
特点:多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
和Serial收集器一样存在Stop The World问题
应用场景:ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为它是除了Serial收集器外,唯一一个能与CMS收集器配合工作的。
Parallel Scavenge 垃圾收集器
Parallel Scavenge收集器是一个更关注吞吐量的收集器,与parnew 类似。
特点:属于新生代收集器也是采用复制算法的收集器,又是并行的多线程收集器(与ParNew收集器类似)。
该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)
GC自适应调节策略:Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。
Parallel Scavenge收集器使用两个参数控制吞吐量:
- XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
- XX:GCRatio 直接设置吞吐量的大小。
Serial Old垃圾收集器
Serial Old是Serial收集器的老年代版本。
特点:同样是单线程收集器,采用标记-整理算法。
应用场景:主要也是使用在Client模式下的虚拟机中。也可在Server模式下使用。
Parallel Old垃圾回收器
是Parallel Scavenge收集器的老年代版本。
特点:多线程,采用标记-整理算法。
应用场景:注重高吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old 收集器。
CMS收集器
注意:“标记”是指将存活的对象和要回收的对象都给标记出来,而“清除”是指清除掉将要回收的对象。
其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”。
初始标记只是标记一下GC Roots能直接关联到的对象,速度很快。
并发标记阶段 :并不会阻碍用户线程正常执行任务,与用户线程并发执行进行标记。
重新标记阶段则是为了修正并发标记期间因用户程序继续动作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
并发清除:对标记的对象进行清除回收。
CMS收集器的缺点:
- 对CPU资源非常敏感。
- 无法处理浮动垃圾,可能出现Concurrent Model Failure失败而导致另一次Full GC的产生。
- 因为采用标记-清除算法所以会存在空间碎片的问题,导致大对象无法分配空间,不得不提前触发一次Full GC。
未完待续
性能调优,实战线上问题排查
。。。。。
参考:
https://blog.csdn.net/qzqanzc/article/details/81008598
jvm之java类加载机制和类加载器(ClassLoader)的详解_翻过一座座山-CSDN博客_类加载器
https://www.cnblogs.com/chenpt/p/9803298.html