文章目录
- JVM
- Java 类加载机制
- 加载
- 连接
- 初始化
- 什么时候会触发初始化?
- 什么时候不会进行初始化?
- 注意
- 小总结
- 使用
- 卸载
- 类加载机制三种方式
- 类加载器 ClassLoader
- JVM 内存模型
- 程序计数器 Program Counter Register
- 虚拟机栈 Stack
- 本地方法栈
- 堆 Heap
- 元空间 metaspace
- 垃圾回收机制
- 概述
- Java 中常用的垃圾收集算法
- 内存泄漏与内存溢出
- Java 的四种引用
JVM
Java 类加载机制
Java 虚拟机规范中,并没有强制约束一个类在什么时候开始被加载,而是交给虚拟机自己去实现(HotSpot 是按需加载,用到该类的时候就加载)。
不管 Java 程序多么复杂,启动了多少线程,它们都处于该 Java 虚拟机进程里。
出现以下情况,JVM 进程将被终止:
- 程序运行到最后,正常结束。
- 程序运行到使用 System.exit() 或 Runtime.getRuntime().exit() 代码结束程序。
- 程序执行过程中遇到未捕获的异常或错误而结束。
- 程序所在平台强制结束 JVM 进程。
一个类从加载到 JVM 内存,到从 JVM 内存中卸载,整个生命周期会经历 7 个阶段:加载、验证、准备、解析、初始化、使用、卸载。验证、准备、解析统称为连接。
加载
- 将 classpath、jar 包、网络、某个磁盘位置下的类的 class 二进制字节流读进来(将类的 .class 文件读入内存);
- 将该二进制字节流所代表的静态存储结构转换为方法区运行时的数据结构;
- 并且为之创建一个 java.lang.Class 对象,放入元空间,即当程序使用任何类时,系统都会为之建一个 java.lang.Class 对象。这个阶段程序员可以干预,自定义类加载器来实现类的加载。
连接
接着进入连接阶段,负责把类的二进制数据合并到 JRE 中。
连接阶段又可分为三个阶段:
- 验证:检验被加载的类是否有正确的内部结构,并与其他类协调一致。
- 准备:负责正式为类变量(静态变量)分配内存并设置类变量默认初始值 0 / null / false ;为常量赋值正式值(等号后的常数值)。
- 解析:将常量池中的符号引用替换为直接引用,例如:String str=“123”;符号引用就是说这里的 “=” 只是一个符号的作用,并没有将常量池中的对象地址引用给 str。
初始化
这个阶段是类加载过程的最后一步,这时 Java 虚拟机才真正开始执行类中编写的 Java 程序代码。
在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,才真正初始化类变量和其他资源。
什么时候会触发初始化?
- new 一个类的对象时,new 对象时,才会执行实例变量、普通代码块、构造器
- 访问 / 修改一个类的静态属性时,不包括 final 修饰的常量,常量已经在编译阶段被放入常量池
- 调用一个类的静态方法时
- 用反射 API 对一个类进行调用
什么时候不会进行初始化?
- 子类引用父类的静态字段时,不会导致子类的初始化,即对于调用静态字段,只有直接定义该字段的类才会被初始化
- 通过数组定义来引用类,不会触发此类的初始化
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
注意
- 初始化子类时,会先初始化父类:
父类静态变量 –> 父类静态代码块 –> 子类静态变量 –> 子类静态代码块 –> 父类变量 –> 父类常规代码块 –> 父类构造器 –> 子类变量 –> 子类常规代码块 –> 子类构造器。 - 当 JVM 启动时,用户需要指定一个要执行的主类,即包含 main() 方法的那个类,JVM 会先初始化这个主类。
小总结
当程序主动使用某个类时,如果该类还未被加载到内存中,系统会通过==加载、连接、初始化==三个步骤来对该类进行初始化,不出意外的话,JVM 会连续完成这三个步骤,所有也把这三个步骤统称为类加载或类初始化。
在初始化阶段,JVM 主要就是对静态属性进行初始化。
在Java类中对静态属性指定初始值的方式:
- 声明静态属性时指定初始值。
- 使用静态代码块为静态属性指定初始值。
初始化步骤:
- 该类还未被加载和连接,则程序先加载并连接该类。
- 该类的直接父类还未被初始化,则先初始化其直接父类。
- 若类中有初始化语句,则系统依次执行初始化语句。
使用
使用该类。
卸载
卸载的条件很苛刻:
- 给类的所有实例已经被 GC,即 JVM 中不存在该类的任何实例;
- 加载该类的 ClassLoader 已经被 GC;
- 该类的 java.lang.Class 对象没有在任何地方被引用,即没有在任何地方通过反射访问该类。
类加载机制三种方式
- 全盘负责:当一个类加载器负责加载某个 Class 时,该 Class 所依赖和引用的其他 Class 也将由该类加载器负责载入,除非显式地使用另一个类加载器载入。
- 父类委托:先让父类加载器试图加载该 Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载。
- 缓存机制:该机制会保证所有被加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存中搜寻该 Class ,只有当缓存中不存在该 Class 对象时,系统才会读取该类的二进制数据,并将其转换成 Class 对象,再存入 cache。这就是为什么修改 Class 后,程序必须重新启动 JVM 修改才会生效。
类加载器 ClassLoader
类加载器就是一个程序(一段 C++ / Java 代码 ),负责将 .class 文件(磁盘或网络上)加载到内存中,并为之生成对应的 java.lang.Class 对象,这个动作可以自定义实现。
JVM 启动时,会形成由三个类加载器组成的初始化类加载器层次结构:三者不是继承关系
- Bootstrap ClassLoader:根类加载器/启动类加载器/父加载器,使用 C++ 语言实现,是虚拟机自身的一部分,加载核心类库(java.lang.* 等)。
其他所有加载器都是 Java 语言实现,独立存在于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader。 - Extension ClassLoader:扩展类加载器/母加载器,加载 jre/lib/ext 目录下的扩展 jar 包。
- System/Application ClassLoader:系统类加载器/应用类加载器,加载应用程序的主函数类。和 Extension ClassLoader 是同级关系,都是 URLClassLoader 的子类,都是 ClassLoader 的间接子类。
- 除了上面的三个,程序员也可以自定义类加载器,指定加载某个路径的类。
通过使用不同的类加载器可以从不同的来源加载类的二进制数据:
- 从本地文件系统加载 class 文件,是绝大部分示例程序的类加载方式。
- 从 JAR 包中加载 class 文件,JDBC 编程时用到的数据库驱动类就是放在 JAR 文件中的,JVM 可以从 JAR 文件中直接加载该 class 文件。
- 通过网络加载 class 文件。
- 把一个 Java 源文件动态编译,并执行加载。
双亲委派机制的执行原理:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载该类,而是把这个请求委派给上层加载器去完成,每一层都是如此,因此,所有的加载请求最终都会传送到最顶层的 BootStrap ClassLoader 中,只有当上一层类加载器反馈自己无法加载该请求时(其搜索范围中没有该类),下一个类加载器才会尝试自己去加载。
作用(好处):
- 避免重复加载,保证类的唯一性;
- 避免核心类库被修改,确保类加载的安全性;
JVM 内存模型
本文讨论的是 JDK 1.8 ,HotSpot 虚拟机。
与 JDK 1.7 之前的区别:
- 没有了方法区,取而代之的是元空间(方法区只是 JVM 规范的概念,并非实际存在,本文以 HotSpot 为例,不同的虚拟机中方法区的实现在不同位置)
- 原来方法区中,运行时常量池中的字符串常量池、静态变量,都存在于堆中了
- 方法区中,其余的(类的加载信息)存在于元空间中,运行时元空间存在于本地内存中。
- JDK 1.7 之前,堆通常被划分为:新生代、老年代、永久代。
JDK 1.8 之后,堆空间将存放元数据(用来描述数据的数据,比如原本方法区存储的类信息、即时编译器编译后的代码等)的永久代,从堆内存转移到了本地内存并改名为元空间。
改变的原因:
- 字符串存在永久代中,容易出现性能问题和内存溢出;
- 不会再有 java.lang.OutOfMemoryError 的内存溢出问题;
- 类和方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出;
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
程序计数器 Program Counter Register
保存的是程序当前执行的指令的地址,当 CPU 需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加 1 或者根据转移指针得到下一条指令的地址(所以也可以说程序计数器保存下一条指令的所在存储单元的地址),如此循环,直至执行完所有的指令。
由于在 JVM 中,多线程是通过线程轮流切换来获得 CPU 执行时间的,因此,在任一具体时刻,一个 CPU 的内核只会执行一条线程中的指令。因此,为了使得每个线程在线程切换后都能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,可以这么说,程序计数器是每个线程所私有的。
在 JVM 规范中,如果线程执行的是非 native 方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是 native 方法,则程序计数器中的值是 undefined 。由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。
虚拟机栈 Stack
也称 Java 栈,是 Java 方法执行的内存模型。
虚拟机中存放的是一个个栈帧,每一个栈帧对应着一个方法。**当程序的执行指令执行一个方法时,就会在虚拟机栈中创建一个对应的栈帧,并将其压入栈中,当方法执行完毕栈帧自动出栈。**因此可知,线程当前执行的方法所对应的栈帧必定位于 Java 栈顶。这也是为什么在 使用递归方法的时候容易导致栈内存溢出的现象了,以及为什么栈区的空间不用程序员去管理了(当然在 Java 中,程序员基本不用管内存分配和释放,因为 Java 有自己的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。
栈帧包含:局部变量表、操作数栈、动态链接、方法的返回地址。
- 局部变量表:方法中的局部变量存放的位置,包括声明的非静态变量和方法形参。对于基本数据类型的变量直接存储值,对于引用类型的变量存储指向对象的引用。局部变量表的大小在编译时就可以确定,因此在程序执行期间其大小不会改变。
- 操作数栈:程序中所有计算过程都是在借助操作数栈。
- 动态链接:用来存放本方法中需要用到的常量池中常量的引用。
- 方法返回地址:保存本方法执行完毕之后,要返回的调用它的地址。
本地方法栈
与 Java 栈的作用和原理非常相似,区别在于 Java 栈为执行 Java 方法服务,本地方法栈为执行本地方法服务。在 HotSpot 虚拟机中直接就把本地方法栈和 Java 栈合二为一。
堆 Heap
- 是 JVM 管理的内存最大的一块,唯一,在虚拟机启动时被创建;
- 唯一的目的是存放对象实例,几乎所有的对象实例本身和数组都是在此分配,指向它们的引用放在 Java 栈;
- 是垃圾收集管理的主要区域,也会被称作 GC ;
- 原来方法区中运行时常量池中的字符串常量池、静态变量,都存在于堆中了
- 被所有线程共享,在 JVM 中只有一个堆
元空间 metaspace
在 JDK 1.8 中,永久代不再存在,存储的类信息、编译后的代码数据等已经移动到了元空间中,元空间并没有处于堆内存,而是直接占用本地内存。
垃圾回收机制
概述
Java 提供了一个系统级的线程,即垃圾回收器线程。用来对每一个分配出去的内存空间进行跟踪,当 JVM 空闲时,自动回收每块可能被回收的内存,GC 是完全自动的,不能被强制执行。程序员最多只能用System.gc() 来建议执行垃圾回收器回收内存,但是具体的回收时间是不可知的。当对象的引用变量被赋值为 null,可能被当成垃圾。
Java 虚拟机的内存区域中,程序计数器、虚拟机栈、本地方法栈,这三个区域是线程私有的,随线程而生,随线程而灭。
垃圾回收重点关注的是堆和方法区部分的内存,常用的算法有:
- 引用计数器算法:
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器都为 0 的对象就是不再被使用的,垃圾收集器将回收该对象使用的内存。
实现简单,效率很高,但是对于对象之间相互循环引用问题难以解决,因此 Java 并没有使用引用计数算法。 - 根搜索算法:
通过一系列的名为 “GC Root” 的对象作为起点,从这些节点向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Root 没有任何引用链相连时,则该对象是不可使用的,垃圾收集器将回收其所占的内存。
在 Java 语言中,可作为 GC Root 的对象包括以下几种对象:
- Java 虚拟机栈中的引用的对象;
- 方法区中的类静态属性引用的对象、常量引用的对象;
- 本地方法栈中的引用对象。
Java 中常用的垃圾收集算法
- 标记-清除算法:
最基础的垃圾收集算法,算法分为 “标记” 和 “清除” 两个阶段,首先标记出所有需要回收的对象,在标记完成之后统一回收所有被标记的对象。
缺点:效率不高、标记清除之后会产生大量的不连续内存碎片,会导致当前程序分配大对象内存时无法找到足够的连续内存。 - 复制算法:
将可用内存按容量分成大小相等的两块,每次只使用其中一块,当这块内存使用完了,就将还存活的对象复制到另一块内存上去,然后把使用过的内存空间一次清理掉。
缺点:可使用的内存降为原来一半。 - 标记-整理算法:
在标记-清除算法基础上做了改进,标记阶段是相同的,标记出所有需要回收的对象,在标记完成之后不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,在移动过程中清理掉可回收的对象,这个过程叫做整理。
优点是内存被整理以后不会产生大量不连续内存碎片。
复制算法在对象存活率高的情况下就要执行较多的复制操作,效率将会变低,而在对象存活率高的情况下使用标记-整理算法效率会大大提高。 - 分代收集算法:
根据内存中对象的存活周期不同,将内存划分为几块,Java 的虚拟机中一般把内存划分为新生代和年老代,当新创建对象时一般在新生代中分配内存空间,当新生代垃圾收集器回收几次之后仍然存活的对象会被移动到年老代内存中,当大对象在新生代中无法找到足够的连续内存时也直接在年老代中创建。
现在的 Java 虚拟机就联合使用了分代复制、标记-清除和标记-整理算法。
内存泄漏与内存溢出
内存泄漏:memory leak,指程序在申请内存后,无法释放已申请的内存空间(无法归还也无法再使用,垃圾回收器也无法回收),内存泄漏堆积后的后果就是内存溢出。
内存溢出:out of memory,指程序申请内存时,没有足够的内存供申请者使用,此时就会报错 OOM,即所谓的内存溢出。
导致内存泄漏:
- 各种资源连接包括数据库连接、网络连接、IO 连接等没有显式调用 close() 关闭,不被 GC 回收导致内存泄露;
- 监听器的使用,在释放对象的同时没有相应删除监听器的时候也可能导致内存泄露;
- 集合类的静态使用最容易出现内存泄漏,因为这些静态变量的生命周期和应用程序一致,所有的对象也不能被释放。
导致内存溢出:
- 内存中加载的数据量过于庞大,如一次性从数据库中取出过多的数据
- 集合类中有指向对象的引用,使用完后未清空,使 JVM 不能回收
- 代码存在死循环或循环产生过多重复的对象实体
- JVM 的启动参数设定过小
- 使用的第三方软件中的 BUG
解决内存溢出:
- 修改 JVM 的启动参数,直接增加内存(使用 -Xms 表示初始堆大小、-Xmx 表示最大堆大小)
- 检查错误日志,看看 OutOfMemory 错误之前是否发生其他异常
- 使用内存查看工具动态查看内存使用情况
Java 的四种引用
- 强引用:用的最多
声明格式:String str=“abc”;
只要某个对象与强引用关联,那么在内存不足的情况下,宁愿抛出 OutOfMemoryError 错误,也不会回收此类对象;解决方式是把 str 置为 null 。 - 软引用:
SoftReference<String> str = new SoftReference<String>(new String("abc"));
只要某个对象与软引用关联,JVM 只会在内存不足的情况下回收该对象。利用这个特性,软引用可以用来做缓存。
软引用适合做缓存,在内存足够时,直接通过软引用取值,无需从真实来源中查询数据,可以显著提升网站性能。当内存不足时,让 JVM 进行内存回收,从而删除缓存,这时候只能从真实来源查询数据。
- 弱引用:
WeakReference<String> str = new WeakReference<String>(new String("abc"));
如果某个对象与弱引用关联,那么当 JVM 在进行垃圾回收时,无论内存是否充足,都会回收此类对象。
- 虚引用:
ReferenceQueue<String> queue = new ReferenceQueue<>();
PhantomReference<String> str = new PhantomReference<String>("abc", queue);
若某个对象与虚引用关联,那么在任何时候都可能被 JVM 回收掉。虚引用不能单独使用,必须配合引用队列一起使用。