简介
JVM:Java Virtual Machine,Java虚拟机,用于运行java程序。
JVM的运行机制:在运行Java程序时,首先会启动jvm,然后由它来负责解释执行Java程序,并且Java程序只能运行于jvm之上,这样利用jvm就可以把Java程序和具体的硬件平台分隔开来,只要在不同的计算机上安装了针对特定平台的jvm,Java程序就可以运行,所以Java才可以实现“一次编译、到处运行”。一个java程序在底层是由一个jvm来运行的,多个程序启动就会存在多个虚拟机实例,多个虚拟机之间没有联系。
sun公司制定了java虚拟机的标准,其它个人或公司依据虚拟机标准来实现自己的虚拟机。
hotspot:sun公司自己实现的jvm,使用最广泛
openjdk:一个项目,代码是hotspot的源码,用c++开发。
虚拟机参数
在随后学习虚拟机的特性的时候,需要设置不同的参数以观察虚拟机的行为,在这里先学习一下虚拟机的参数。
参数的分类:
- 标准参数:以 “-” 开头。所有类型的虚拟机都必须实现这些参数的功能,而且向后兼容
- 非标准参数:以 “-X” 开头。虚拟机默认实现这些参数的功能,但是不保证所有虚拟机都会失效,且不保证向后兼容
- 非stable参数:以 “-XX” 开头。各个虚拟机的实现会有所不同,将来可能会随时取消,需要慎重使用
标准参数
常用的标准参数:
- -help:输出java标准参数列表及其描述
- -Dproperty=value:设置 键值对,运行在此jvm上的应用程序可用,在程序中,使用System.getProperty(“property”) 可以获取value的值,如果value有空格,需要使用双引号引起来
- -X:输出非标准的参数列表及其描述
- -verbose:class :输出jvm载入类的相关信息,当jvm报告说找不到类或者类冲突时可以进行诊断
- -verbose:gc :输出每次GC的相关情况
- -verbose:jni :输出native方法调用的相关情况,一般用于诊断jni调用错误信息
案例1:-Dproperty=value,向Java程序传递参数
第一步:配置参数
第二步:在Java程序中获取参数
java">public static void main(String[] args) {String aa = System.getProperty("aa");System.out.println("aa = " + aa); // bb
}
非标准参数
常用的非标准参数:
- -Xms512M:设置堆内存初始值是512M
- -Xmx512M:设置堆内存最大值是512M
- -Xmn200M:设置年轻代大小为200M
非stable参数
常用的非stable参数:
-XX:+<option>
:开启option参数-XX:-<option>
:关闭option参数,option前面 “+” 表示开启参数,“-” 表示关闭参数-XX:<option>=<value>
:将option参数之间的值设为value- -XX:+PrintGCDetails:打印GC详细信息
- -XX:MaxMetaspaceSize=8M:设置元空间的最大大小
内存泄漏和内存溢出
内存泄漏和内存溢出:
- 内存泄漏:程序中已动态分配的堆内存由于某种原因未被释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统奔溃等问题
- 内存溢出:程序在申请内存时,没有足够的内存空间供其使用,出现 out of memory
什么情况下会造成内存泄漏?如果一个对象引用被无意识的保留起来了,那么垃圾回收器不会处理这个对象,也不会处理该对象引用的其他对象,即使这样的对象只有少数几个,也可能会导致很多的对象被排除在垃圾回收之外,从而对性能造成重大影响
内存溢出的详细分类:
- 堆内存溢出:OutOfMemoryError,发生在堆中的内存溢出,
- 栈内存溢出:StackOverflowError,发生在栈中的内存溢出。方法无限制的递归调用会造成栈溢出异常。
- 方法区的内存溢出:OutOfMemoryError: Metaspace,由于加载的类信息过多,例如动态生成了许多类,导致内存使用超出限制。
JVM的组成
JVM的组成部分:
- 类加载器:负责加载字节码文件
- 内存结构:方法区、堆、虚拟机栈、程序计数器、本地方法栈
- 执行引擎:解释器、即时编译器(JIT Complier)、GC
类加载器
类加载:加载字节码文件,生成一个类对象的过程
类加载器:通过一个类的全限定名,读取类文件,同时生成类对象。虚拟机允许用户自定义类加载器,以便让应用程序自己决定如何去获取所需要的类。类加载器除了可以加载类文件,还可以加载普通文件,它会把普通文件作为一个外部资源读取到内存中。
JVM提供的类加载器:
- 启动类加载器:Bootstrap ClassLoader,内置在虚拟机中,是虚拟机的一部分,负责加载Java核心库,如java.lang包,核心库位于JAVA_HOME/jre/lib目录下
- 扩展类加载器:Extension ClassLoader,负责加载Java的扩展库,扩展库位于JAVA_HOME/jre/lib/ext目录下
- 应用类加载器:Application ClassLoader,负责加载CLASSPATH下的类库,这些类通常是用户编写的代码或第三方库。如果应用程序没有自定义类加载器,一般情况下使用这个类加载器。
类加载器的层次结构:启动类加载器 -> 扩展类加载器 -> 应用类加载器,启动类加载器是扩展类加载器的父类,扩展类加载器是应用类加载器的父类。
工作机制:双亲委派模型
双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己直接尝试加载这个类,而是将请求委派给它的父类加载器,每个层次的类加载器都是如此,因此所有的类加载请求最终都会落到顶层的启动类加载器,如果上层的类加载器反馈自己无法处理加载该类的请求,因为每个加载器都有自己负责的路径,那么下层的类加载器才会尝试自己加载。
双亲委派模型的作用:保证一个类只被一个类加载器加载一次、防止核心API被随意的篡改
案例:查看类加载器的实例
java">public static void main(String[] args) {// 应用类加载器,加载用户编写的类ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();System.out.println("appClassLoader = " + appClassLoader); // sun.misc.Launcher$AppClassLoader@18b4aac2// 扩展类加载器,应用类加载器的父类ClassLoader extClassLoader = appClassLoader.getParent();System.out.println("extClassLoader = " + extClassLoader); // sun.misc.Launcher$ExtClassLoader@330bedb4// 启动类加载器,扩展类加载器的父类,它是由C语言实现的ClassLoader bootClassLoader = extClassLoader.getParent();System.out.println("bootClassLoader = " + bootClassLoader); // null
}
案例:使用类加载器加载一个类
java">public static void main(String[] args) {// 1、使用反射中的API加载一个类,反射底层也是调用了类加载器Class<?> userClass;try {// Class.forNameuserClass = Class.forName("org.User");} catch (ClassNotFoundException e) {throw new RuntimeException(e);}System.out.println("userClass = " + userClass); // class org.User// 2、通过类加载器加载一个类// 获取应用类加载器ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();Class<?> personClass;try {// 仅仅只是加载一个类,不会执行初始化操作personClass = appClassLoader.loadClass("org.Person");} catch (ClassNotFoundException e) {throw new RuntimeException(e);}System.out.println("personClass = " + personClass); // class org.Person
}
总结:用Class.forName方法加载类和用ClassLoader加载类的区别:
- Class.forName()方法:调用的CLassLoader来实现的,但它在加载类时会对类进行初始化,初始化就是执行类中的静态代码块、对静态变量赋值
- ClassLoader:仅仅只是加载一个类,不会执行初始化操作。
类的生命周期
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期包括:加载、验证、准备、解析、初始化、使用、卸载七个阶段。这些阶段是按顺序开始不是按顺序进行或完成,它们通常都是互相交叉混合进行的。
加载:加载阶段是由类加载器来完成的。在加载阶段,虚拟机主要完成三件事:读取类文件、生成类对象、把类加载到方法区
验证:验证字节码文件是否符合规范,包括文件格式验证、元数据验证、字节码验证、符号引用验证
准备:为类变量在方法区中分配内存并设置初始值,为被final修饰的常量赋予实际值。
解析:虚拟机将常量池中的符号引用转化为直接引用的过程
- 符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量,只要能无歧义的定位到目标;
- 直接引用:可以指向目标的指针、相对偏移量或者是一个能直接或间接定位到目标的句柄。
初始化:为静态变量赋值并且执行静态代码块中的语句
加载、验证、准备、解析、初始化,验证、准备、解析阶段合在一起构成连接阶段。
类初始化时机:只有对类主动使用的时候才导致类的初始化。类的主动使用包括:创建类的实例、调用类中的静态成员时、反射、初始化某个类的子类,则其父类也会被初始化、jvm启动时main方法所在的类
验证时的具体动作:
- 文件格式验证:验证class文件字节流是否符合格式规范,并且能够被当前版本的虚拟机处理,这里主要是对魔数、主版本号、常量池等等的校验
- 元数据验证:主要是对字节码描述的信息进行语义分析,对数据类型做出验证
- 字节码验证:对类的方法做出验证,保证类的方法在运行时不会做出危害虚拟机安全的事
- 符号引用验证:虚拟机将符号引用转化为直接引用,对类自身以外的信息进行校验,确保解析动作能够完成
静态代码块:在类初始化时调用
代码块:在对象实例化时调用,和构造方法一样,并且它的调用在构造方法之前
自定义类加载器
用户可以定义自己的类加载器,自定义类加载器的可以带来哪些好处:
- 修改类加载方式:可以更加灵活的定义类的加载方式
- 扩展加载源:例如从网络或其它硬件平台加载类
- 依赖隔离:例如tomcat等web容器,在同一个web容器中,每个web应用依赖的类库应该是隔离的,不同web应用之间不能相互影响
- 热部署:使用两个类加载器来加载程序,一个加载不会变的资源,另一个加载需要被修改的资源,减少需要被加载的资源
- 加密字节码:出于保护源码的目的,把字节码特殊加密,只有使用自定义的类加载器才能加载字节码
自定义类加载器的实现:继承ClassLoader接口,指定类加载器对应的路径、重写findClass方法
自定义类加载器默认的父类加载器是应用类加载器
类加载器相当于一个命名空间,如果两个类的全限定名相同,但是它们的类加载器不同,那么它们可以存在于同一个虚拟机中,否则是不可以的。
案例1:自定义类加载器
java">public class MyClassLoader extends ClassLoader {// 1、指定类加载器对应的路径private final String classpath;public MyClassLoader(String classpath) {// 经测试,没有这行代码,自定义类加载器的父类也是应用类加载器super(ClassLoader.getSystemClassLoader());this.classpath = classpath;}// 2、重写findClass方法,指定把类文件从磁盘加载到内存的逻辑@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException {// 检查该类的class文件是否已被加载Class<?> loadedClass = findLoadedClass(name);if (loadedClass != null) {return loadedClass;}// 字节数组,用于存储class文件的字节流byte[] bytes;try {// 获取class文件的字节流bytes = loadByte(name);} catch (Exception e) {throw new ClassNotFoundException("类加载失败" + name);}if (bytes != null) {// 如果字节数组不为空,则将class文件加载到JVM中,返回class文件对象return this.defineClass(name, bytes, 0, bytes.length);} else {throw new ClassNotFoundException();}}// 根据文件名加载字节码public byte[] loadByte(String className) throws IOException {// 拼接class文件路径,File.separator返回当前系统的分隔符String filePath = classpath+ File.separator+ className.replace(".", File.separator)+ ".class";File file = new File(filePath);// 相当于一个缓存区,动态扩容,也就是随着写入字节的增加自动扩容try (ByteArrayOutputStream arrayOutputStream = new ByteArrayOutputStream();// 声明输入流InputStream inputStream = Files.newInputStream(file.toPath())) {int len;byte[] bytes = new byte[1024];while ((len = inputStream.read(bytes)) != -1) {arrayOutputStream.write(bytes, 0, len);}return arrayOutputStream.toByteArray();} catch (IOException e) {throw new RemoteException();}}
}
测试:
java">public static void main(String[] args) throws Exception {// 这里使用一个绝对路径作为测试路径final String PATH = "/Users/work/project/java/java-high-level/my-classloader-class";MyClassLoader myClassLoader = new MyClassLoader(PATH);System.out.println("自定义类加载器的父类 = "+ myClassLoader.getParent()); // sun.misc.Launcher$AppClassLoader@18b4aac2// 加载类Class<?> aClass = myClassLoader.loadClass("Test");System.out.println("aClass = " + aClass);
}
自定义类加载器的要点:
- 指定路径。按照规范,类加载器只可以加载指定路径下的类
- 定义读取类文件到内存中的逻辑
双亲委派模型被破坏的情况
双亲委任模型不是一个强制性的约束模型,而是一个建议。在Java的世界中大部分的类加载器都遵循这个模型,但也有例外,例如,SPI机制就会破坏双亲委派模型。
在双亲委派模型中,越基础的类由越上层的加载器进行加载,基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API。但没有绝对,如果基础类需要调用会用户的代码该怎么办呢?例如,在SPI机制中,SPI的代码由启动类加载器去加载,但它需要加载由用户实现的SPI接口实现类,但启动类加载器不可能“认识“这些代码,因为这些类不在rt.jar中。为了解决这个问题,Java设计团队引入了线程上下文类加载器,它实际是应用类加载器,只不过被存储在线程对象中,新线程创建时继承父类的上下文类加载器。有了线程上下文加载器,SPI机制使用这个线程上下文加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上已经违背了双亲委派模型。Java中所有涉及SPI机制的加载动作基本上都采用这种方式。
SPI机制的相关代码:
java">// 这是rt.jar中的类,它会调用上下文类加载器,加载指定service的实现类,实现类是由用户提供的
public static <S> ServiceLoader<S> load(Class<S> service) {ClassLoader cl = Thread.currentThread().getContextClassLoader();return ServiceLoader.load(service, cl);
}
类加载的源码
类加载器的实现:除了启动类加载器,其它类加载器都由java语言实现,且全部继承自java.lang.ClassLoader
- 启动类加载器的实现:是由C语言实现的
- 扩展类加载器的实现:sun.misc.Launcher类的静态内部类,ExtClassLoader
- 应用类加载器的实现:sun.misc.Launcher类的静态内部类,AppClassLoader
ClassLoader的继承体系:
java">// ClassLoader:类加载器的顶层接口,负责加载字节码文件到虚拟机中,生成一个类对象,每个类对象中都包含一个
// 指向类加载器的引用。每个类加载器都只能加载固定路径下的类文件
public abstract class ClassLoader {// 类加载器的父类加载器private final ClassLoader parent;
}// SecureClassLoader:继承了ClassLoader,定义类对象、权限校验
public class SecureClassLoader extends ClassLoader { }// URLClassLoader:继承了SecureClassLoader,从一个URL中加载类和资源
public class URLClassLoader extends SecureClassLoader implements Closeable { }// Launcher
public class Launcher { // sun.misc包下的类private static Launcher launcher = new Launcher(); // 持有自己的实例,静态属性private ClassLoader loader; // 存储应用类加载器// 应用类加载器static class AppClassLoader extends URLClassLoader { }// 扩展类加载器static class ExtClassLoader extends URLClassLoader {private static volatile ExtClassLoader instance;}
}
应用类加载器的实例时如何创建的?
java">// 1、在ClassLoader中,实例化应用类加载器
private static synchronized void initSystemClassLoader() {// 同步方法,只实例化一次if (!sclSet) {// scl就是存放应用类加载器的成员变量if (scl != null)throw new IllegalStateException("recursive invocation");sun.misc.Launcher l = sun.misc.Launcher.getLauncher();if (l != null) {Throwable oops = null;// 实例化应用类加载器scl = l.getClassLoader();// 省略代码 ....}// 实例化完成sclSet = true;}
}// 2、Launcher:实例化类加载器的整体流程
public Launcher() { // 构造方法// 扩展类加载器ExtClassLoader var1;try {var1 = Launcher.ExtClassLoader.getExtClassLoader();} catch (IOException var10) {throw new InternalError("Could not create extension class loader", var10);}// 应用类加载器try {this.loader = Launcher.AppClassLoader.getAppClassLoader(var1); // 创建应用类加载器时,传入了扩展类加载器的实例} catch (IOException var9) {throw new InternalError("Could not create application class loader", var9);}// 为当前线程设置上下文类加载器Thread.currentThread().setContextClassLoader(this.loader);// 省略代码
}// 3、创建应用类加载器的核心代码,在AppClassLoader中,参数是父类加载器
public static ClassLoader getAppClassLoader(final ClassLoader var0) throws IOException {final String var1 = System.getProperty("java.class.path");// 1、类加载器对应的类路径final File[] var2 = var1 == null ? new File[0] : Launcher.getClassPath(var1);return (ClassLoader)AccessController.doPrivileged(new PrivilegedAction<AppClassLoader>() {public AppClassLoader run() {// 2、将类加载器对应的类路径转换为URLURL[] var1x = var1 == null ? new URL[0] : Launcher.pathToURLs(var2);// 3、创建类加载器实例,需要指定父类加载器、类加载器对应的路径return new AppClassLoader(var1x, var0);}});
}
需要注意的是,创建类加载器实例的时候,需要指定父类加载器、类加载器对应的路径
类加载器是如何加载类的?loadClass方法
java">protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {// getClassLoadingLock方法会根据类名生成一个锁对象,避免锁竞争,所以不同的类可以同时加载synchronized (getClassLoadingLock(name)) { // 同步代码块// 判断类是否已经被加载Class<?> c = findLoadedClass(name);if (c == null) { // 如果没有long t0 = System.nanoTime();try {// 调用父类加载器,如果父类加载器是null,证明它是启动类加载器if (parent != null) {c = parent.loadClass(name, false);} else {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); // 由子类来加载,子类可以实现自己的findClass方法,定义加载类的逻辑// 统计信息sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);sun.misc.PerfCounter.getFindClasses().increment();}}if (resolve) {resolveClass(c);}return c;}
}// 应用类加载器的findClass方法,定义在它的父类URLClassLoader中
protected Class<?> findClass(final String name) throws ClassNotFoundException {final Class<?> result;try {result = AccessController.doPrivileged(new PrivilegedExceptionAction<Class<?>>() {public Class<?> run() throws ClassNotFoundException {// 寻找类文件的逻辑String path = name.replace('.', '/').concat(".class"); // 将"."替换为"/",然后拼接上.classResource res = ucp.getResource(path, false); // 把类文件作为外部资源加载进来if (res != null) {try {return defineClass(name, res); // 定义类对象} catch (IOException e) {throw new ClassNotFoundException(name, e);} catch (ClassFormatError e2) {if (res.getDataError() != null) {e2.addSuppressed(res.getDataError());}throw e2;}} else {return null;}}}, acc);} catch (java.security.PrivilegedActionException pae) {throw (ClassNotFoundException) pae.getException();}if (result == null) {throw new ClassNotFoundException(name);}return result;
}
总结:从这段代码中,基本可以看出双亲委派模型的工作机制
JVM的内存结构
JVM的内存分为5个部分:程序计数器、虚拟机栈、本地方法栈、堆内存、方法区
程序计数器
Program Counter Register,线程私有,用于记录当前线程正在执行的字节码指令的地址。程序计数器是java虚拟机规范中规定的唯一一个不会存在内存溢出的区域
虚拟机栈
JVM stacks,线程私有,每个线程运行时需要的内存被称为虚拟机栈,栈内存不涉及垃圾回收。
栈内存大小和线程数的关系:理论上,栈内存越大,可运行的线程数就会越少
栈帧
栈帧是虚拟机栈的基本存储单位,存储了方法调用期间的所有信息,每个方法在被调用时都会创建一个新的栈帧,方法执行,栈帧入栈,方法结束,栈帧出栈。栈内存是先进后出的,也就是最先调用的方法最后出栈。每个线程只能有一个活动栈帧,对应着正在执行的方法。
栈帧中存储的数据:
- 局部变量表:存储方法内的局部变量,包括方法形参和局部变量,局部变量表的大小在方法编译时确定,并且存储在字节码中。局部变量表的基本存储单位是槽(slot),int和引用等类型的变量占一个槽,long、double占两个槽。
- 操作数栈:一个栈帧中只有一个操作数栈,用于存储方法执行过程中的操作数、中间结果,操作数栈的最大深度在方法编译时确定。
- 动态链接:在运行时由符号引用转换为直接引用的称为动态链接
- 返回地址:存储方法返回后继续执行的地址
- 其它
虚拟机只会对虚拟机栈执行以栈帧为单位的压栈和出栈操作。
关于操作数栈,要注意,在概念模型中,两个栈帧是相互立的,但是大多数虚拟机的实现都会进行优化,令下面的部分操作数栈与上面的局部变量表重叠在一块,这样在方法调用的时候以共用一部分数据,无需进行额外的参数复制传递。
动态链接
动态链接是什么?在运行时,将符号引用转换为直接引用,称为动态链接,符号引用是一个符号,例如类或方法的名称,直接引用是指向内存地址的指针,表示符号所代表的实例位于内存中的什么位置。和动态链接类似的,还有静态解析。
符号引用:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符,符号引用存储在常量池
静态解析:符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析,例如,静态方法、被final修饰的方法,这些都是确定的,所以它们可以在类加载阶段就被转换为直接引用。
动态链接:符号引用在每一次运行时转换为直接引用,这种转化称为动态链接,例如,成员方法,它们可能被子类重写,所以是不确定的,JVM需要根据对象的实际类型解析方法的内存地址,所以会在运行期才由符号引用转换为直接引用,称为动态链接。动态链接允许程序在运行时根据对象的实际类型调用相应的方法实现,从而支持多态。
本地方法栈
Native Method Stacks,调用本地方法时,需要为本地方法提供的内存空间。本地方法指不是由java代码编写的方法,这种方法被native关键字修饰,并且没有方法体。java代码不可以直接和操作系统交互,所以需要依赖c或c++来和操作系统交互。
堆
heap,被线程共享,存放对象,是垃圾回收的主要区域
方法区
线程共享,存储字节码数据,方法区的垃圾回收主要就是针对类型的卸载。
常量池
常量池分为静态常量池和运行时常量池:
- 静态常量池是编译时的概念,每个类文件中都有自己的静态常量池,存储类中的字面量、符号引用,字面量是字符串、声明为final的常量值等,符号引用是类和接口的全限定名、字段名称和描述符、方法名称和描述符,当类被加载,符号引用会变成真正的内存地址
- 运行时常量池,当类被加载时,它的常量池信息就会放入运行时常量池,并且把符号引用变成真正的内存地址。每个class文件对应一个运行时常量池。
在运行时,常量池位于方法区,是一张表,虚拟机指令根据这张表找到要执行的类名、方法名、字面量等信息,要注意,每个类都有自己的常量池。
常量池中的所有字符串,在运行时都会被放到字符串常量池中,常量池中只存储它们的引用
字符串常量池
存储字符串的常量池,只有一个并且位于堆中,所有类共有一个字符串常量池。
字符串常量池的运行机制:在运行时,每当创建字符串常量时,jvm会首先检索字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的实例引用,如果字符串不存在于常量池中,就会实例化并且将其放到常量池中,由于字符串的不可变性,常量池中一定不存在两个相同的字符串。
关于字符串常量池的几个注意事项:
-
String类的intern方法:它可以在运行时向字符串常量池中添加数据。当调用 intern 方法时,如果常量池中已经该字符串,则返回池中的字符串;否则将此字符串添加到常量池中,并返回字符串的引用
-
字符串字面量相加:编译期优化,在编译期就会被优化为相加后的结果,因为字符串常量相加的结果是固定的
-
字符串变量相加:StringBuilder,底层使用StringBuilder的append方法实现的,然后再使用toString方法转成String类型,StringBuilder的toString方法中会new一个新的String对象,所以字符串变量相加的结果指向堆内存
StringTable:在hotspot vm中,字符串常量池是通过StringTable类实现的,它是一个hash表,默认大小1009,这个StringTable在每个虚拟机中只有一份,被所有的类共享
StringTable的特性:
- 常量池中的字符串仅仅是符号,第一次使用时才会变成实例
- 字符串常量池中没有重复的字符串常量
方法区的物理存储
Jdk1.8前存储在永久代中:永久代位于堆内存中,和新生代、老年代的地址是连续的。在早期,当自定义类加载器使用不普遍的时候,类几乎是“静态的”并且很少被卸载和回收,因此类也可以被看成“永久的”
Jdk1.8中存储在元空间中:元空间位于本地内存,也就是操作系统的内存,字符串常量池被移到了堆中
jdk8为什么移除永久代:难以指定大小,类及方法的信息等比较难确定其大小,例如动态代理等问题,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出
垃圾回收机制
垃圾回收机制:Java语言不需要程序员手动释放内存,JVM会自动清除掉不使用的对象,这种机制就是垃圾回收机制。
Java语言规范没有明确地说明JVM使用哪种垃圾回收算法,但任何一种垃圾回收算法一般要做2件基本的事情:发现无用对象、回收被无用对象占用的内存空间
垃圾回收机制的特点
- 负责回收堆内存和方法区中的对象,不负责回收栈内存数据
- 不会回收物理连接,比如数据库连接、IO流、Socket,这些是操作系统级别的资源,需要程序员手动释放,垃圾回收机制只关注内存资源
- 程序无法精确控制垃圾回收机制的执行
- 垃圾回收机制回收任何对象之前,会先调用它的finalize方法
垃圾收集主要是针对堆和方法区进行,程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。
发现无用对象的算法
引用计数法
引用计数是垃圾收集器中的早期策略。
运行原理:
- 给每个对象分配一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1,例如,变量被置为null、或生命周期已经结束、或者被赋为其它值,对象的引用计数减1
- 任何引用计数器为0的对象可以被当作垃圾收集。
优缺点:
- 优点:引用计数收集器可以很快地执行,交织在程序运行中,对程序需要不被长时间打断的实时环境比较有利。
- 缺点:无法检测出循环引用。例如,对象A持有对象B的引用,对象B持有对象A的引用,具体来说,对象A中的某个属性指向对象B,对象B中的某个属性执行对象A,它们互相在等待对方被回收,造成死循环
根搜索算法(可达性分析算法)
又名可达性分析算法。根搜索算法是从离散数学中的图论引入的。
运行原理:程序把所有的引用关系看作一张图,从一个Gc Root节点开始,寻找它引用的节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。无用的节点会被垃圾回收器回收
GC Root:当前不会被回收的对象。
可以当中GC Root的对象:
- 虚拟机栈中引用的对象:活动线程执行过程中局部变量引用的对象
- 本地方法栈中引用的对象
- 方法区中静态变量引用的对象:也就是类的静态变量引用的对象,只要类没有被卸载,这些静态变量引用的对象就是可达的
- 方法区中常量引用的对象:运行时常量池中的常量引用的对象。例如,字符串常量和类类型的常量
优缺点:
- 优点:不会有循环引用的问题
- 缺点:执行速度较慢
用于清除对象的算法
标记清除算法 Mark Sweep
执行步骤:从gc root对象进行扫描,对存活的对象对象标记。标记完毕后,再扫描整个空间中需要被回收的对象,进行内存回收
优缺点:
- 优点:速度快。不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,
- 缺点:会造成内存碎片
标记整理算法 Mark Compact
执行步骤:在标记-清除算法的基础上,又进行了对象的移动
优缺点:
- 优点:不会产生内存碎片
- 缺点:相较于标记清除算法,成本更高
复制算法 copying
执行步骤:把堆内存分为两块大小相同的内存空间,A和B,使用A区进行内存分配,当A区满了,垃圾收集器就扫描活动对象,并将活动的对象放到B区,使用B区进行内存分配,当B区满了,把B中存活的对象放到A中,如此循环
优缺点:
- 优点:不会产生内存碎片
- 缺点:只有一半的内存可以使用
分代垃圾回收策略
因为不同的对象的生命周期是不同的,所以,针对不同生命周期的对象采用不同的回收算法,依据它们的特性进行垃圾回收,可以提高回收效率
分代垃圾回收策略,将堆内存从逻辑上分为几个部分:
- 年轻代:Young Generation,存放新创建的对象。年轻代会又会被细分为3部分:
- Eden:存放新创建的对象
- From survivor:存放执行垃圾回收后剩余的对象
- to survivor:存放执行垃圾回收后剩余的对象
- 老年代:Old Generation,存放执行垃圾回收后剩余的对象,老年代的内存大小一般比新生代大,能存放更多的对象。如果对象比较大(比如长字符串或者大数组),并且新生代的剩余空间不足,则这个大对象会直接被分配到老年代上。
不同部分的内存占比:
-
年轻代和老年代的内存大小为 1 : 2
-
年轻代中每个部分的占比:Eden区和两个survivor区之间的内存大小为8: 1: 1,大部分对象在Eden区中生成。
不同部分使用的垃圾回收算法:
- 新生代:复制算法
- 老年代:标记整理算法。因为对象的生命周期较长,不需要过多的复制操作,所以一般采用标记整理的回收算法。
MinorGC
一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Minor GC。Minor gc是针对新生代的垃圾回收,采用复制算法。
执行步骤:
- 当Eden区第一次满的时候,将Eden区的垃圾对象回收清除,并将存活的对象复制到 from survivor,此时 to survivor 是空的。
- 下一次Eden区满的时候,将Eden和From survivor中的所有垃圾对象清除,并将存活的对象复制到to survivor,此时 from survivor 是空的。
- 每次在from survivor和to survivor之间交换,都会保证一方中没有任何剩余对象,如果另一方不足以装下所有的对象,则直接转移到老年代。
- 每执行一次垃圾回收,存活的对象的年龄就会加1,如果对象的年龄大于15,会被移到老年代
Full GC
当老年代内存满时触发Full GC,也就是新生代、老年代都进行回收,Full GC发生频率比较低,并且成本比较高。full gc是针对新生代和老年代的垃圾回收,新生代的垃圾回收采用复制算法,老年代的垃圾回收采用标记整理算法
空间担保策略
当触发minor gc时,会判断老年代的剩余大小,如果老年代不够大,触发full gc,避免在minor gc过程中出现内存分配失败的情况。判断老年代是否足够大的标准是 老年代剩余最大连续空间 > (历次minor gc晋升的平均大小 || 大于新生代所有对象的大小总和)
STW
stop the world。gc会暂停用户线程,等垃圾回收结束后,用户线程才回复运行。因为垃圾回收需要移动对象位置,所以需要暂停用户线程。暂停期间程序无法响应用户的请求,所以称为stop the world
从垃圾回收的角度看对象的状态
对象的状态:
- 可触及状态:对象被创建后并且有引用的状态
- 可复活状态:当程序不再有任何引用变量引用该对象时,该对象就进入可复活状态,在这个状态下,垃圾回收器会准备释放它所占用的内存。
- 不可触及状态:当虚拟机执行完所有可复活对象的finalize()方法后,如果这些方法都没有使该对象转到可触及状态,垃圾回收器才会真正回收它占用的内存。
Obejct类的finalize方法:protected void finalize() throws Throwable
,在回收对象前,gc会调用它的finalize方法,finalize方法有可能使该对象重新转到可触及状态。因为finalize方法的执行比较麻烦,所以不推荐使用finalize方法
垃圾回收器
在虚拟机中用于执行垃圾回收任务的组件,不同的垃圾回收器有不同的特性,依据不同的场景,选择最合适的垃圾回收器。
垃圾回收器的分类:
- 串行:单线程,适合堆内存较小的情况
- 吞吐量优先:多线程,让单位时间内,STW的时间最短。吞吐量,指垃圾回收的时间占程序运行的时间,占的越少,吞吐量越高
- 响应时间优先:多线程,尽可能让单次STW的时间最短
吞吐量和响应时间:
-
吞吐量:高吞吐量则可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
-
响应时间:响应时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体验;
jvm_798">jvm使用的垃圾回收器
Serial GC 串行
Serial收集器:垃圾收集器的原始实现,使用单线程。它占用内存空间比较小,因此这是嵌入式应用程序的首选垃圾收集器类型,新生代采用标记复制算法,老年代采用标记整理算法。
开启串行垃圾回收器的参数: -XX:+UseSerialGC=Serial+SerialOld。Serial运行在新生代,SerialOld运行在老年代
Parallel GC 吞吐量优先
JVM中的默认垃圾收集器,也被称为 吞吐量收集器。在新生代使用多线程,在老年代使用单线程
开启吞吐量优先的垃圾回收器的参数:
- -XX:+UseParallelGC -XX:+UseParallelOldGC:默认参数,只要设置一个,默认另一个参数也会生效
- -XX:+ParallelGCThreads=n:设置线程数,默认等于程序的CPU个数
- -XX:+UseAdaptiveSizePolicy:动态地调整堆内存中各部分的调整
- -XX:+GCTimeRatio=ratio:单词时间内GC时间占运行时间的比例。 1 / (1 + ratio),默认值是19
- -XX:+MaxGCPauseMillis=ms:每次GC最大暂停时间,默认200ms
Parallel GC 是由 PS Scavenge 和 PS MarkSweep 组成的垃圾收集器组合,适用于吞吐量优先的场景。PS Scavenge 是用于新生代的垃圾收集器,基于 复制算法 实现。它通过并行的方式快速回收新生代中的垃圾对象。PS MarkSweep 是用于老年代的垃圾收集器,基于 标记-清除算法 实现。它通过并行的方式标记存活对象,并清除未被标记的对象。
CMS GC 响应时间优先
Concurrent Mark Sweep GC,并发垃圾回收器,关注停顿时间,适用于对响应时间要求高的应用。
开启响应时间优先的垃圾回收器的参数:-XX:+UseConcMarkSweepGC
案例
案例1:查看虚拟机默认使用的垃圾回收器
程序运行时添加一个虚拟机参数 -XX:+PrintCommandLineFlags
打印结果:
打印内容讲解:默认使用吞吐量优先的垃圾回收器
案例2:查看程序的内存信息
程序运行时添加一个虚拟机参数 -XX:+PrintGCDetails,这个参数会打印GC详情,同时在程序结束后,会打印内存详情
打印内容:
打印内容讲解:因为main方法中没有代码,所以没有GC信息,只打印程序结束时的内存信息
-
Heap:表示打印堆内存的信息
-
PSYoungGen,新生代,总共75M,已用6M
- eden space,eden区,总共65M,已用10%
- from space,from区,总共10M,已用0%
- to space,to区,总共10M,已用0%。由于from区和to区中有一个区在运行过程中是不可以分配内存的,所以 from区 或 to区 加 eden区 等于新生代的内存大小
-
ParOldGen,老年代,约170M,已用0K
- object space:对象空间,老年代中只有一个部分,object space,约170M,已用0K
-
Metaspace,元空间,存放字节码信息,从之前的介绍中可以知道,java 1.8 把方法区放到了元空间
-
这个案例演示了一个普通的java程序,内存是如何分配的,新生代加老年代总共分配了250M内存,新生代75M,老年代170M,新生代用了6M,老年代没有使用。
案例3:查看一个发生了gc的程序的gc信息
添加虚拟机参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
java代码:这个程序在运行过程中会触发gc,因为设置堆内存最大20M,并且在代码中使用了20M的内存
java">private static final int _1M = 1024 * 1024;
private static final int _2M = 2 * _1M;
private static final int _5M = 5 * _1M;
private static final int _6M = 6 * _1M;public static void main(String[] args) {// 程序中创建一些比较大的对象,触发gcArrayList<byte[]> list = new ArrayList<>();list.add(new byte[_2M]);list.add(new byte[_2M]);list.add(new byte[_5M]);list.add(new byte[_6M]);list.add(new byte[_5M]);
}
打印内容:
打印内容讲解:从打印内容看,发生了两次GC,一次是普通的gc,也就是Minor GC,第二次是Full GC,后面的括号中是触发GC的原因。
- Allocation Failure,分配失败;
- Ergonomics,中文含义为人类工程学,它表示JVM根据某种条件,判断需要进行一次FULL GC,以提高程序性能,所以触发了Full GC。
- PSYoungGen:新生代
- ParOldGen:老年代
箭头前是原大小,箭头后的GC后的大小,括号中是区域大小,例如新生代、老年代。可以看到,Full Gc的位置包括新生代、老年代、元空间。Minor Gc由于应用内存分配失败而触发,仅仅包括新生代。
案例4:堆内存溢出时的垃圾收集器
和案例3一样配置相同的虚拟机参数,-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
java代码:制造堆内存溢出,观察垃圾收集器的运行
java">private static final int _1M = 1024 * 1024;
private static final int _6M = 6 * _1M;public static void main(String[] args) {new Thread(() -> {List<byte[]> list = new ArrayList<>();list.add(new byte[_6M]);list.add(new byte[_6M]);list.add(new byte[_6M]);list.add(new byte[_6M]);}).start();try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}System.out.println("Hello World");
}
打印内容:
打印内容讲解:程序可以正常结束。一个线程的内存溢出并不会影响整个进程的执行,除非进程中只有一个线程。一共执行了5次gc,当内存无法分配,并且GC也无法清理出足够的内存时,程序就会报内存溢出错误。
JVM中的引用
引用:指向对象的指针,依据不同的需求,jvm中提供了四种类型的引用
引用的分类:
- 强引用:Strong Reference,jvm中最常见的引用,一个普通的、指向对象的变量,就是一个强引用。对于有强引用的实例,它绝对不会被垃圾回收器回收
- 软引用:Soft Reference,对于只有软引用的实例,在系统内存不足时会被回收,软引用通常用于实现缓存
- 弱引用:Weak Reference,对于只有弱引用的实例,只要垃圾回收机制一运行,不管jvm的内存空间是否足够,总会回收该对象占用的内存
- 虚引用:Phantom Reference,主要作用是跟踪对象被垃圾回收的状态,仅仅是提供了一种确保对象被垃圾回收以后,做某些事情的机制。如果一个对象持有虚引用,那么它就和没有任何引用一样,虚引用并不会决定对象的生命周期。虚引用必须和引用队列联合使用。PhantomReference的get方法总是返回null,因此无法访问对应的引用对象
案例:使用软引用来做缓存
第一步:使用软引用的缓存类
java">public class SoftReferenceCache<K, V> {private final Map<K, SoftReference<V>> cache = new ConcurrentHashMap<>();public void put(K key, V value) {cache.put(key, new SoftReference<>(value));}public V get(K key) {SoftReference<V> softReference = cache.get(key);return softReference != null ? softReference.get() : null;}
}
第二步:测试,测试程序需要添加jvm参数 -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
java">private static final int _1M = 1024 * 1024;
private static final int _4M = 4 * _1M;public static void main(String[] args) {SoftReferenceCache<String, byte[]> softReferenceCache = new SoftReferenceCache<>();softReferenceCache.put("aaa", new byte[_4M]);softReferenceCache.put("bbb", new byte[_4M]);softReferenceCache.put("ccc", new byte[_4M]);softReferenceCache.put("ddd", new byte[_4M]);softReferenceCache.put("fff", new byte[_4M]);System.out.println("softReferenceCache.get(\"aaa\") = " + softReferenceCache.get("aaa"));System.out.println("softReferenceCache.get(\"bbb\") = " + softReferenceCache.get("bbb"));System.out.println("softReferenceCache.get(\"ccc\") = " + softReferenceCache.get("ccc"));System.out.println("softReferenceCache.get(\"ddd\") = " + softReferenceCache.get("ddd"));System.out.println("softReferenceCache.get(\"fff\") = " + softReferenceCache.get("fff"));
}
结果:观察程序的gc日志,可以发现,当内存不足时,会回收掉软引用指向的对象。
案例:弱引用和ThreadLocal
测试当发生gc时,如果一个对象只有弱引用指向它,对象会不会被回收?
代码:运行时需要添加虚拟机参数 -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
java">private static final int _5M = 4 * 1024 * 1024;public static void main(String[] args) throws InterruptedException {List<Entry> list = new ArrayList<>();ThreadLocal<byte[]> threadLocal = new ThreadLocal<>(); // 强引用for (int i = 0; i < 4; i++) {if (i == 0) {list.add(new Entry(threadLocal, new byte[_5M]));} else {list.add(new Entry(new ThreadLocal<byte[]>(), new byte[_5M]));}}System.gc(); // 手动触发gcThread.sleep(1000L); // 等待gc结束for (Entry ref : list) {System.out.println("entry = " + ref + ", 键=" + ref.get() + ", 值=" + ref.value);}
}static class Entry extends WeakReference<ThreadLocal<?>> {Object value;Entry(ThreadLocal<?> k, Object v) {super(k);value = v;}
}
运行结果:
总结:当发生gc时,如果一个对象只有弱引用指向它,对象会被回收。在这个案例中,list是gc root,存储了entry对象,entry中threadLocal是一个弱引用,所以当运行gc时,虚拟机发现threadLocal只有一个弱引用,就会回收它,与之相反的是第一个threadLocal实例,它在代码中是有强引用的,所以不会被回收。要注意,list中存储的是Entry对象,所以是list指向entry而entry又指向threadLocal,所以threadLocal在代码中只有entry指向它的弱引用。
引用相关的源码
顶层抽象类:Reference,所有引用对象的抽象基类,定义了所有引用对象的通用操作
java">public abstract class Reference<T> {private T referent; // 需要被gc特殊对待的实例Reference(T referent) { // 构造方法this(referent, null);}public T get() { // 获取引用return this.referent;}public void clear() { // 清除引用this.referent = null;}
}
软引用:SoftReference
java">public class SoftReference<T> extends Reference<T> {static private long clock;private long timestamp;public SoftReference(T referent) { // 构造方法super(referent);this.timestamp = clock;}public T get() {T o = super.get();if (o != null && this.timestamp != clock)this.timestamp = clock;return o;}
}
弱引用:WeakReference
java">public class WeakReference<T> extends Reference<T> {public WeakReference(T referent) { // 构造方法super(referent);}
}
虚引用:PhantomReference
java">public class PhantomReference<T> extends Reference<T> {public T get() {return null;}public PhantomReference(T referent, ReferenceQueue<? super T> q) { // 构造方法super(referent, q);}
}
所以在之前的案例中,Entry继承WeakReference,然后调用它的构造方法,传入需要被特殊对待的实例,然后指向该实例的引用就是一个弱引用。
方法区的垃圾回收
方法区的垃圾回收:主要是回收不再使用的类
类的生命周期:类的生命周期和其对应的类加载器是相同的。只要类加载器存活,其加载的类的元数据也是存活的,因而不会被回收掉。
类加载器的生命周期:JVM提供的类加载器的生命周期和JVM的生命周期是一致的。
类被判断是否可以回收的规则:
- 该类所有的实例都已经被回收
- 该类的类加载器已经被会回收
- 该类对应的类对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
总结:使用应用类加载器加载的类,通常不会被回收,因为应用类加载器始终存活。
直接内存
直接内存:Direct Memory,操作系统使用的内存,直接分配给jvm使用。直接内存分配和回收的成本比较高,但是读写性能高,不受jvm内存的管理,需要程序员自己负责gc。
在Java中可以通过Unsafe类来申请直接内存。
方法区位于元空间,而元空间位于直接内存
即时编译器 JIT
Just-In-Time Compiler。JVM在运行时监控代码的执行频率,识别出频繁执行的代码段,这些代码被称为热点代码。JIT将热点代码编译成本地机器码,这个过程只发生一次,编译后的机器码会被缓存起来,以便后续执行时直接使用。编译后的机器码直接运行在硬件上,避免了解释执行的开销,从而显著提高程序的执行效率
jvm_1123">jvm参数总结
设置内存:
- -Xmx:堆大小的最大值。当前主流虚拟机的堆都是可扩展的,案例 -Xmx256M,设置堆内存最大占256兆
- -Xms:堆大小的最小值。可以设置成和 -Xmx 一样的值
- -Xmn:新生代的大小。现代虚拟机都是“分代”的,因此堆空间由新生代和老年代组成。新生代增大,相应地老年代就减小。Sun官方推荐新生代占整个堆的3/8
- -Xss:每个线程的栈大小。该值影响一台机器能够创建的线程数上限
- -XX:SurvivorRatio=:Eden和Survivor的比值。基于“复制”的垃圾收集器又会把新生代分为一个Eden和两个Survivor,如果该参数为8,就表示Eden占新生代的80%,而两个Survivor各占10%。默认值为8
GC相关:
-
GC详情:-XX:+PrintGCDetails
-
-XX:PretenureSizeThreshold=:直接晋升到老年代的对象大小。大于这个参数的对象将直接在老年代分配。默认值为0,表示不启用
-
-XX:MaxTenuringThreshold=:对象晋升到老年代的年龄。对象每经过一次 Minor GC 后年龄就加1,超过这个值时就进入老年代。默认值为15