Java 问答知识总结篇-基础知识
Java 问答知识总结篇-JVM
Java 问答知识总结篇-多线程&并发编程
Java 问答知识总结篇-网络基础
Java 问答知识总结篇-Spring
Java 问答知识总结篇-Spring Boot
Java 问答知识总结篇-Mybatis
Java 问答知识总结篇-MySQL
Java 问答知识总结篇-Redis
Java 问答知识总结篇-MQ
Java 问答知识总结篇-Nginx
Java 问答知识总结篇-分布式
Java 问答知识总结篇-Spring Cloud
Java 问答知识总结篇-Dubbo
Java 问答知识总结篇-Zookeeper
Java 问答知识总结篇-ElasticSearch
Java 问答知识总结篇-Netty
Java 问答知识总结篇-场景分析题
JVM 系列文章:Java 虚拟机
什么是 JVM 内存结构?
JVM 将虚拟机分为 5 大区域,程序计数器、虚拟机栈、本地方法栈、java 堆、方法区:
- 程序计数器:线程私有的,是一块很小的内存空间,作为当前线程的行号指示器,用于记录当前虚拟机正在执行的线程指令地址;
- 虚拟机栈:线程私有的,每个方法执行的时候都会创建一个栈帧,用于存储局部变量表、操作数、动态链接和方法返回等信息,当线程请求的栈深度超过了虚拟机允许的最大深度时,就会抛出 StackOverFlowError;
- 本地方法栈:线程私有的,保存的是 native 方法的信息,当一个 jvm 创建的线程调用 native 方法后,jvm 不会在虚拟机栈中为该线程创建栈帧,而是简单的动态链接并直接调用该方法;
- 堆:java 堆是所有线程共享的一块内存,几乎所有对象的实例和数组都要在堆上分配内存,因此该区域经常发生垃圾回收的操作;
- 方法区:存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码数据。即永久代,在 jdk1.8 中不存在方法区了,被元数据区替代了,原方法区被分成两部分;1:加载的类信息;2:运行时常量池;加载的类信息被保存在元数据区中,运行时常量池保存在堆中。
什么是 Java 内存模型?
Java 内存模型(下文简称 JMM)就是在底层处理器内存模型的基础上,定义自己的多线程语义。它明确指定了一组排序规则,来保证线程间的可见性。
这一组规则被称为 Happens-Before, JMM 规定,要想保证 B 操作能够看到 A 操作的结果(无论它们是否在同一个线程),那么 A 和 B 之间必须满足 Happens-Before 关系:
- 单线程规则:一个线程中的每个动作都 happens-before 该线程中后续的每个动作
- 监视器锁定规则:监听器的解锁动作 happens-before 后续对这个监听器的锁定动作
- volatile 变量规则:对 volatile 字段的写入动作 happens-before 后续对这个字段的每个读取动作
- 线程 start 规则:线程 start() 方法的执行 happens-before 一个启动线程内的任意动作
- 线程 join 规则:一个线程内的所有动作 happens-before 任意其他线程在该线程 join() 成功返回之前
- 传递性:如果 A happens-before B, 且 B happens-before C, 那么 A happens-before C
怎么理解 happens-before 呢?如果按字面意思,比如第二个规则,线程(不管是不是同一个)的解锁动作发生在锁定之前?这明显不对。happens-before 也是为了保证可见性,比如那个解锁和加锁的动作,可以这样理解,线程 1 释放锁退出同步块,线程 2 加锁进入同步块,那么线程 2 就能看见线程 1 对共享对象修改的结果。
Java 提供了几种语言结构,包括 volatile, final 和 synchronized, 它们旨在帮助程序员向编译器描述程序的并发要求,其中:
- volatile - 保证可见性和有序性
- synchronized - 保证可见性和有序性; 通过管程(Monitor)保证一组动作的原子性
- final - 通过禁止在构造函数初始化和给 final 字段赋值这两个动作的重排序,保证可见性(如果 this 引用逃逸就不好说可见性了)
编译器在遇到这些关键字时,会插入相应的内存屏障,保证语义的正确性。
有一点需要注意的是,synchronized 不保证同步块内的代码禁止重排序,因为它通过锁保证同一时刻只有一个线程访问同步块(或临界区),也就是说同步块的代码只需满足 as-if-serial 语义 - 只要单线程的执行结果不改变,可以进行重排序。
所以说,Java 内存模型描述的是多线程对共享内存修改后彼此之间的可见性,另外,还确保正确同步的 Java 代码可以在不同体系结构的处理器上正确运行。
heap 和 stack 有什么区别?
- 申请方式
stack:由系统自动分配。例如,声明在函数中一个局部变量 int b; 系统自动在栈中为 b 开辟空间
heap:需要程序员自己申请,并指明大小,在 c 中 malloc 函数,对于 Java 需要手动 new Object()的形式开辟
- 申请后系统的响应
stack:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
heap:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
- 申请大小的限制
stack:栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在 WINDOWS 下,栈的大小是 2M(默认值也取决于虚拟内存的大小),如果申请的空间超过栈的剩余空间时,将提示 overflow。因此,能从栈获得的空间较小。
heap:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的, 自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见, 堆获得的空间比较灵活,也比较大。
- 申请效率的比较
stack:由系统自动分配,速度较快。但程序员是无法控制的。
heap:由 new 分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。
- heap 和 stack 中的存储内容
stack:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址, 然后是函数的各个参数,在大多数的 C 编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。
当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
heap:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容由程序员安排。
谈谈对 OOM 的认识?如何排查 OOM 的问题?
除了程序计数器,其他内存区域都有 OOM 的风险。
- 栈一般经常会发生 StackOverflowError,比如 32 位的 windows 系统单进程限制 2G 内存,无限创建线程就会发生栈的 OOM
- Java 8 常量池移到堆中,溢出会出 java.lang.OutOfMemoryError: Java heap space,设置最大元空间大小参数无效;
- 堆内存溢出,报错同上,这种比较好理解,GC 之后无法在堆中申请内存创建对象就会报错;
- 方法区 OOM,经常会遇到的是动态生成大量的类、jsp 等;
- 直接内存 OOM,涉及到 -XX:MaxDirectMemorySize 参数和 Unsafe 对象对内存的申请。
排查 OOM 的方法:
- 增加两个参数
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof
,当 OOM 发生时自动 dump 堆内存信息到指定目录; - 同时 jstat 查看监控 JVM 的内存和 GC 情况,先观察问题大概出在什么区域;
- 使用 MAT 工具载入到 dump 文件,分析大对象的占用情况,比如 HashMap 做缓存未清理,时间长了就会内存溢出,可以把改为弱引用 。
谈谈 JVM 中的常量池?
JVM 常量池主要分为Class 文件常量池、运行时常量池,全局字符串常量池,以及基本类型包装类对象常量池。
- Class 文件常量池。class 文件是一组以字节为单位的二进制数据流,在 java 代码的编译期间,我们编写的 java 文件就被编译为.class 文件格式的二进制数据存放在磁盘中,其中就包括 class 文件常量池。
- 运行时常量池:运行时常量池相对于 class 常量池一大特征就是具有动态性,java 规范并不要求常量只能在运行时才产生,也就是说运行时常量池的内容并不全部来自 class 常量池,在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是 String.intern()。
- 全局字符串常量池:字符串常量池是 JVM 所维护的一个字符串实例的引用表,在 HotSpot VM 中,它是一个叫做 StringTable 的全局表。在字符串常量池中维护的是字符串实例的引用,底层 C++实现就是一个 Hashtable。这些被维护的引用所指的字符串实例,被称作”被驻留的字符串”或”interned string”或通常所说的”进入了字符串常量池的字符串”。
- 基本类型包装类对象常量池:java 中基本类型的包装类的大部分都实现了常量池技术,这些类是 Byte,Short,Integer,Long,Character,Boolean,另外两种浮点数类型的包装类则没有实现。另外上面这 5 种整型的包装类也只是在对应值小于等于 127 时才可使用对象池,也即对象不负责创建和管理大于 127 的这些类的对象。
OOM 和 SOF 在什么情况下会引起
OOM(OutOfMemoryError 异常)
除了程序计数器外,虚拟机内存的其他几个运行是区域都有发生 OutOfMemoryError(OOM)异常的可能。
Java Heap 溢出
一般的异常信息:java.lang.OutOfMemoryError:Java heap spacess
Java 堆用于存储对象实例,我们只要不断的创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除对象,就会在对象数量达到最大堆容量限制后产生内存溢出异常。
出现这种异常,一般手段是先通过内存映像分析工具对 dump 出来的堆转存快照进行分析,重点是确认内存中的对象是否是必要的,先分清是因为内存泄漏还是内存溢出引起的。
如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的应用链。于是就能找到泄漏对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收。
如果不存在泄漏,那几应该检查虚拟机的参数(-Xmx 和-Xms)的设置是否适当。
虚拟机栈和本地方法栈溢出
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常;
- 如果虚拟机在拓展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常;
- 如果虚拟机栈在配置的时候指定不可拓展。就不会存在虚拟机栈拓展请求内存空调的操作,因此就不会出现 OutOfMemoryError 异常,只会是 StackOverflowError 异常。
运行时常量池异常
异常信息:java.lang.OutOfMemoryError:PermGenspace
如果要想运行时常量池中添加内容,最简单的做法就是使用 String.intern()这个 Native 方法。该方法的作用是:
如果常量池中已经包含一个等于此 String 的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的应用。由于常量池分配在方法区中。我们可以通过-XX:PermSize 和-XX:MaxPermSize 限制方法区的大小,从而间接限制其中常量池的容量。
方法区溢出
异常信息:java.lang.OutOfMemoryError:PermGenspace
方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。也有可能是方法区中保存的 class 对象没有被及时回收掉或者 class 信息占用的内存超过了我们的配置。
方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,判定条件是很苛刻的。在经常动态生成大量 class 的应用中,要特别注意这点。
SOF(堆栈溢出 StackOverflowError)
当应用程序递归太深而发生堆栈溢出时,抛出该错误。
因为栈空间是有限的,一旦出现死循环或者是大量的递归调用,在不断的压栈过程中,造成栈容量不能满足需求而导致溢出。
栈溢出的原因:递归调用、大量循环或者死循环,全局变量是否过多,数组、List、Map 数据过大。
JVM 内存结构简介
JVM 定义了不同原型时数据区,他们是用来执行应用程序的。某些区域随着 JVM 启动及销毁,另外一些区域的数据是线程性独立的,随着线程创建和销毁。JVM 内存模型总体架构图如下:(来自官网)
JVM 在执行 Java 程序时,会把它管理的内存划分为若干个区域,每个区域都有自己的用户和创建销毁时间。主要分为两个大部分,线程私有区和共享区。
线程私有区
程序计数器
当同时进行的线程数超过 CPU 数或其内核数时,就要通过时间片轮询分派 CPU 的时间资源,会发生线程切换。这时,每个线程就需要一个属于自己的计数器来记录下一条要运行的指令。如果执行的是 Java 方法,计数器记录正在执行的 Java 字节码地址。如果执行的是 native 本地方法,则计数器为空。
虚拟机栈
线程私有,随线程创建而创建。管理 Java 方法执行的内存模型。每个方法执行时都会创建一个栈帧来存储方法的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。栈的大小决定了方法调用的可达深度(可通过-Xss 参数设置虚拟机栈大小)。栈的大小可以是固定的,或者是动态拓展的。如果请求的栈深度大于最大可用深度,则抛出 StackOverflowError;如果栈是可动态拓展的,但没有内存空间支持拓展,则抛出 OutOfMemoryError。
本地方法栈
与虚拟机栈作用相似,但它不是为 Java 方法服务的,而是本地方法(C 语言)。
线程共享区
方法区
线程共享的,用于存放被虚拟机加载的类的元数据信息,如常量、静态变量和即时编译器编译后的代码。若要分代,算是永久代。另外运行时常量池存放编译生成的各种常量。(Hotspot 虚拟机确认一个类的定义信息不会被使用,也会将其回收。回收的基本条件至少有:所有该类的实例被回收,而且装载该类的 ClassLoader 被回收)
堆
存放对象实例和数组,是垃圾回收的主要区域,分为新生代和老年代。刚创建的对象在新生代的 Eden 区中,经过 GC 后进入新生代的 S0 区中,再经过 GC 进入新生代的 S1 区中,15 次 GC 后仍存在就进入老年代。这是按照一种回收机制进行划分的,不是固定的。若堆的控件不够实例分配,则会出现 OutOfMemoryError 错误。
堆和栈的区别
栈是运行时单位,代表着逻辑,内含基本数据类型和堆中对象引用,所在区域连续,没有碎片;堆是存储单位,代表着数据,可被多个栈共享(包括成员中基本数据类型、引用和引用对象),所在区域不连续,会有碎片。
功能不同
栈内存用来存储局部变量和方法调用,而堆内存用来存储 Java 中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。
共享性不同
栈内存是线程私有的。 堆内存是所有线程共有的。
异常错误不同
如果栈内存或者堆内存不足都会抛出异常。 栈空间不足:StackOverFlowError 或者 OutOfMemoryError。 堆空间不足:OutOfMemoryError。
空间大小
栈的空间大小远远小于堆的。
对象的分配规则
对象优先分配在 Eden 区,如果 Eden 区没有足够的空间时,虚拟机执行一次 Minor GC;
- 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象) ,这样做的目的是避免在 Eden 区和两个 Survivor 区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
- 长期存活的对象进入老年代 ,虚拟机为每个对象定义了一个年龄计数器,如果对象经过了 1 次 Minor GC 那么对象会进入 Survivor 区,之后每经过一次 Minor GC 那么对象的年龄加 1,直到达到阀值对象进入老年代。(默认年龄阈值是 15)
- 动态判断对象的年龄 ,如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
- 空间分配担保 ,每次进行 Minor GC 时,JVM 会计算 Survivor 区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次 Full GC,如果小于,则检查 HandlePromotionFailure 设置,如果 true 则只进行 Minor GC,如果 false 则进行 Full GC。
JVM 加载 class 文件的原理机制
JVM 中类的装载是由类加载器(ClassLoader)和它的子类来实现的,Java 中的类加载器是一个重要的 Java 运行时系统组件,它负责在运行时查找和装入类文件中的类。 由于 Java 的跨平台性,经过编译的 Java 源程序并不是一个可执行程序,而是一个或多个类文件。当 Java 程序需要使用某个类时,JVM 会确保这个类已经被加载、连接(验证、准备和解析)和初始化。类的加载是指把类的.class 文件中的数据读入到内存中,通常是创建一个字节数组读入.class 文件,然后产生与所加载类对应的 Class 对象。加载完成后,Class 对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后 JVM 对类进行初始化。
初始化内容
- 如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;
- 如果类中存在初始化语句,就依次执行这些初始化语句。 类的加载是由类加载器完成的。
类加载器分类
类加载器包括:根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)和用户自定义类加载器(java.lang.ClassLoader 的子类)。类加载过程采取了双亲委托机制(PDM)。PDM 更好的保证了 Java 平台的安全性,在该机制中,JVM 自带的 Bootstrap 是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM 不会向 Java 程序提供对 Bootstrap 的引用。下面是关于几个类加载器的说明:
- Bootstrap:一般用本地代码实现,负责加载 JVM 基础核心类库(rt.jar);
- Extension:从 java.ext.dirs 系统属性所指定的目录中加载类库,它的父加载器是 Bootstrap;
- System:又叫应用类加载器,其父类是 Extension。它是应用最广泛的类加载器。它从环境变量 classpath 或者系统属性 java.class.path 所指定的目录中记载类,是用户自定义加载器的默认父加载器。
类的生命周期
类的生命周期包括这几个部分,加载、连接、初始化、使用和卸载,其中前三部是类的加载的过程,如下图:
- 加载:查找并加载类的二进制数据,在 Java 堆中也创建一个 java.lang.Class 类的对象;
- 连接:连接又包含三块内容:验证、准备、初始化;
- 验证,文件格式、元数据、字节码、符号引用验证;
- 准备,为类的静态变量分配内存,并将其初始化为默认值,如果类变量被 final 修饰,不会设置初始值,而是将其进行实际赋值;
- 解析,把类中的符号引用转换为直接引用(换句话说,解析是在引用转换的时候触发,无转换无解析)。
- 初始化:为类的静态变量赋予正确的初始值(初始化过程是单线程的,如果存在多线程,由一个线程执行,其他线程自旋);
- 使用:new 出对象程序中使用;
- 卸载:执行垃圾回收。
Java 的对象结构
Java 对象由三个部分组成:对象头、实例数据、对齐填充。
对象头: 由两部分组成,第一部分存储对象自身的运行时数据:哈希码、GC 分代年龄、锁标识状态、线程持有的锁、偏向线程 ID(一般占 32/64 bit)。第二部分是指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。
实例数据: 用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)。
对齐填充: JVM 要求对象起始地址必须是 8 字节的整数倍(8 字节对齐)。
在可达性分析算法和引用计数法中被标记的对象就一定会被回收吗
不一定,此两种算法标记这些可被回收对象后,虚拟机会对其进行最后的判定。
- 首先判定当前对象是否有必要执行 finalize 方法,即这个类有没有重写 finalize 方法;
- 如果没有重写,则最终判定此对象死亡;
- 如果重写,会判定此方法是否已经被 JVM 执行过,如果执行过,则被判定为对象死亡;
- 如果没有执行,会将对象加入到 F-Queue 队列中,JVM 会自动创建低优先级的 Finalizer 线程去执行 F-Queue 队列中的对象;
- 如果此对象的 finalize 方法中重新与引用链上的对象关联,就可以不被回收,获得重生,否则被判定为对象死亡。
需要注意:
- finalize 方法如果存在死循环或者其他复杂逻辑,会导致 F-Queue 队列其他对象永久等待,会导致内存回收系统崩溃,JVM 不保证等待 finalize 方法执行结束;
- finalize 方法运行代价较高,不确定性大,不建议使用该方法。
垃圾收集算法
GC 最基础的算法有标记清除法、标记整理法、复制算法、分代收集算法,我们常用的垃圾回收器一般都采用分代收集算法。
标记-清除算法
步骤:
- 第一步:利用可达性去遍历内存,把存活对象和垃圾对象进行标记;
- 第二步:在遍历一遍,将所有标记的对象回收掉。
特点:
效率不行,标记和清除的效率都不高;标记和清除后会产生大量的不连续的空间分片,可能会导致之后程序运行的时候需分配大对象而找不到连续分片而不得不触发一次 GC。
标记-整理法
步骤:
- 第一步:利用可达性去遍历内存,把存活对象和垃圾对象进行标记;
- 第二步:将所有的存活的对象向一端移动,将端边界以外的对象都回收掉;
特点:
适用于存活对象多,垃圾少的情况;需要整理的过程,无空间碎片产生。
复制算法
将内存按照容量大小分为大小相等的两块,每次只使用一块,当一块使用完了,就将还存活的对象移到另一块上,然后在把使用过的内存空间移除。
特点:
不会产生空间碎片;内存使用率极低。
分代收集算法
根据内存对象的存活周期不同,将内存划分成几块,java 虚拟机一般将内存分成新生代和老生代,在新生代中,有大量对象死去和少量对象存活,所以采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;老年代中因为对象的存活率高,没有额外的空间对他进行分配担保,所以采用标记清理或者标记整理算法进行回收。
对比
JVM 调优命令有哪些
Sun JDK 监控和故障处理命令有jps
、jstat
、jmap
、jhat
、jstack
、jinfo
。
jps
JVM Process Status Tool,显示指定系统内所有的 HotSpot 虚拟机进程。
命令格式:jps [options] [hostid]
-l
:输出应用程序 main class 的完整 package 名或者应用程序的 jar 文件完整路径名;-m
:输出传递给 main 方法的参数,在嵌入式 jvm 上可能是 null;-v
:输出传递给 JVM 的参数;-V
:隐藏传递给 JVM 的参数;-p
:只显示 pid,不显示 class 名称、jar 名和传递给 main 方法的参数。
jstat
JVM statistics Monitoring 是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据。
命令格式:jstat [options vmid [interval[s|ms] [count]]]
-class
:显示类装载、卸载数量、总空间以及类装载所消耗的时间;-compiler
:显示 JIT 编译的相关信息;-gc
:显示和 GC 相关的堆和方法区信息;-gcapacity
:显示各个代的容量以及使用情况;-gcmetacapacity
:显示 metaspace 的大小和使用情况;-gcnew
:显示新生代的信息;-gcold
:显示老年代和永久代(元数据区)的信息;-gcoldcapacity
:显示老年代的信息;-gcutil
:显示垃圾收集信息;-gccause
:显示垃圾回收的相关信息;-printcompilation
:输出 JIT 编译的方法信息
jmap
JVM Memory Map 命令用于生成 heap dump 文件。
命令格式:jmap [option] vmid
[pid]
:查看进程的内存映像信息;-heap
:显示 Java 堆详细信息;-histo
:显示堆中对象的统计信息,包括类、实例数量、合计数量;-clstats
:打印类加载器信息;-finalizerinfo
:显示在 F-Queue 队列等待 Finalizer 线程执行 finalizer 方法的对象;-dump
:生成堆转储快照。(jmap -dump:file=filename,format=b
)
jhat
JVM Heap Analysis Tool 命令是与 jmap 搭配使用,用来分析 jmap 生成的 dump,jhat 内置了一个微型的 HTTP/HTML 服务器,生成 dump 的分析结果后,可以在浏览器中查看。
命令:jhat [filename]
此命令一般不会使用的原因:
- 在生产导出日志文件后,直接在生产服务器中分析日志对性能消耗较大;
- 将日志放到本地分析,有更多的优秀日志分析工具替代 jhat。
jstack
用于生成 java 虚拟机当前时刻的线程快照。
命令格式:jstack [option] vmid
-l
:打印关于锁的附加信息,例如 java.util.concurrent 的 ownable synchronizer 列表打印的是运行中的栈信息;-F -l
:和 jstack -l 相对应,打印的是挂起的栈信息(如死锁);-m
:打印 java 和 native c/C++框架的所有栈信息。
jinfo
JVM Configuration info 这个命令作用是实时查看和调整虚拟机运行参数。
命令格式:jinfo [option] <name> vmid
[pid]
:输出全部的参数和系统属性;-flag name
:输出对应名称的参数;-flag [+|-]name
:开启或者关闭对应名称的参数;-flag name=value
:设定对应名称的参数;-flags
:输出全部的参数;-sysprops
:输出系统属性,和 System.getProperties()方法获取的相同。
常用的调优工具有哪些
常用调优工具分为两类,jdk 自带监控工具:jconsole 和 jvisualvm,第三方有:MAT(MemoryAnalyzer Tool)、GChisto。
- jconsole,Java Monitoring and Management Console 是从 java5 开始,在 JDK 中自带的 java 监控和管理控制台,用于对 JVM 中内存,线程和类等的监控;
- VisualVM,jdk 自带全能工具,可以分析内存快照、线程快照;监控内存变化、GC 变化等;
- MAT,Memory Analyzer Tool,一个基于 Eclipse 的内存分析工具,是一个快速、功能丰富的 Java heap 分析工具,它可以帮助我们查找内存泄漏和减少内存消耗;
- GChisto,一款专业分析 gc 日志的工具。
JVM 常用的性能调优的参数有哪些
设定堆内存大小
-Xmx
:堆内存最大限制;-Xms
:堆内存的初始大小
设定新生代大小
新生代不宜太小,否则会出现频繁的 Minor GC,不断刷新对象进入老年代,一般新生代占堆内存的 2/8 为宜,也是官方推荐的比率。
-XX:NewSize
:新生代大小;-XX:NewRatio
:新生代和老生代占比;-XX:SurvivorRatio
:伊甸园空间和幸存者空间的占比(一般不设置,采用默认的 8:1 即可)
设定垃圾回收器
- 年轻代用
-XX:+UseParNewGC
(ParNew GC 是以低延迟为核心,注重的是应用系统,提高用户的使用体验,Parallel Scavenge 是注重吞吐量,主要是计算型的服务使用) - 年老代用
-XX:+UseConcMarkSweepGC
对象一定分配在堆内吗,为什么?
对象不是一定分配在堆内的,Java 通过逃逸分析,那些逃不出方法的对象会在栈上分配。
逃逸分析
逃逸分析(Escape Analysis),是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。
逃逸分析的好处
- 栈上分配:可以降低垃圾收集器运行的频率;
- 同步消除:如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作可以不需要同步;
- 标量替换:把对象分解成一个个基本类型,并且内存分配不再是分配在堆上,而是分配在栈上。这样的好处有,第一减少内存使用,因为不用生成对象头。第二程序内存回收效率高,并且 GC 频率也会减少。
详细内容文章
Java 的即时编译(Just In Time, JIT)及其优化
什么是 Stop The world?什么是 OopMap?什么是安全点?
进行垃圾回收的过程中,会涉及对象的移动。为了保证对象引用更新的正确性,必须暂停所有的用户线程,像这样的停顿,虚拟机设计者形象描述为 Stop The World。也简称为 STW。
在 HotSpot 中,有个数据结构(映射表)称为 OopMap。一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,记录到 OopMap。在即时编译过程中,也会在特定的位置生成 OopMap,记录下栈上和寄存器里哪些位置是引用。
这些特定的位置主要在:
- 循环的末尾(非 counted 循环)
- 方法临返回前 / 调用方法的 call 指令后
- 可能抛异常的位置
这些位置就叫作安全点(safepoint)。用户程序执行时并非在代码指令流的任意位置都能够在停顿下来开始垃圾收集,而是必须是执行到安全点才能够暂停。
安全点、安全区域和中断方式
在 Stop The World 的时候,会涉及安全点、安全区域以及中断方式的概念。
安全点(safe point)
- HotSpot 通过 OoMap 来记录安全点,安全点是定在指令上,但不是每一条指令都可以作为安全点;
- 当指令让程序运行时间较长,此指令不会被定为安全点。
安全区域(safe region)
- 存在 sleep 或者 blocked 状态的线程无法响应 JVM 中断请求;
- 在此区域中引用关系不会发生变化,可以安全进行 GC。
中断方式
- 抢先式中断:不需要线程执行代码主动配合,直接中断全部线程,如果线程执行在非安全点位置,等待其执行到安全点后中断;
- 主动式中断:需要线程执行代码主动配合,GC 开始时设置一个标志,每个线程主动轮询标志,发现标志为 true,主动中断,轮询的动作是在线程执行到每个安全点时触发。
指针碰撞和空闲列表
指针碰撞
一般情况下,JVM 的对象都放在堆内存中(发生逃逸分析除外)。当类加载检查通过后,Java 虚拟机开始为新生对象分配内存。如果 Java 堆中内存是绝对规整的,所有被使用过的的内存都被放到一边,空闲的内存放到另外一边,中间放着一个指针作为分界点的指示器,所分配内存仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的实例,这种分配方式就是指针碰撞。
空闲列表
如果 Java 堆内存中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,不可以进行指针碰撞了,虚拟机必须维护一个列表,记录哪些内存是可用的,在分配的时候从列表找到一块大小匹配的空间分配给对象实例,并更新列表上的记录,这种分配方式就是空闲列表。
如何判断一个对象是否存活?
判断一个对象是否存活,分为两种算法 1:引用计数法;2:可达性分析算法;
引用计数法
给每一个对象设置一个引用计数器,当有一个地方引用该对象的时候,引用计数器就+1,引用失效时,引用计数器就-1;当引用计数器为 0 的时候,就说明这个对象没有被引用,也就是垃圾对象,等待回收;
缺点:无法解决循环引用的问题,当 A 引用 B,B 也引用 A 的时候,此时 AB 对象的引用都不为 0,此时也就无法垃圾回收,所以一般主流虚拟机都不采用这个方法。(容易导致内存泄漏问题)
可达性分析法
从一个被称为 GC Roots 的对象向下搜索,如果一个对象到 GC Roots 没有任何引用链相连接时,说明此对象不可用,在 java 中可以作为 GC Roots 的对象有以下几种:
- 虚拟机栈中引用的对象
- 方法区类静态属性引用的变量
- 方法区常量池引用的对象
- 本地方法栈 JNI 引用的对象
但一个对象满足上述条件的时候,不会马上被回收,还需要进行两次标记;第一次标记:判断当前对象是否有 finalize()方法并且该方法没有被执行过,若不存在则标记为垃圾对象,等待回收;若有的话,则进行第二次标记;第二次标记将当前对象放入 F-Queue 队列,并生成一个 finalize 线程去执行该方法,虚拟机不保证该方法一定会被执行,这是因为如果线程执行缓慢或进入了死锁,会导致回收系统的崩溃;如果执行了 finalize 方法之后仍然没有与 GC Roots 有直接或者间接的引用,则该对象会被回收。
JVM 有哪些垃圾收集器
年轻代的垃圾收集器
年轻代的垃圾收集器有 Serial 收集器、ParNew 收集器、Parallel Scanvenge 收集器,针对不同场景下,使用不同的垃圾收集器。
Serial 收集器
- 是 JVM 最基本的、历史发展最悠久的收集器
- 单线程收集器,在进行 GC 时,必须暂停其他所有工作线程,直到 GC 结束
- 对于单 CPU 的服务器表现很优秀,无线程交互的开销,专心做垃圾回收
ParNew 收集器
- Serial 收集器的多线程版本
- 响应速度优先,适用于一些应用项目,追求用户体验
- 默认开启的回收线程数量和 CPU 数量相同
Parallel Scanvenge 收集器
- 多线程垃圾回收
- 吞吐量优先,适用于一些计算型项目,用户交互少,吞吐量=运行用户代码时间/(运行用户代码时间+GC 时间)
老年代的垃圾收集器
老年代的垃圾收集器有 Serial Old 收集器、Parallel Old 收集器、CMS 收集器。
Serial Old 收集器
- Serial 收集器老年代版本
- 使用标记整理算法
- 单线程收集器,在进行 GC 时,必须暂停其他所有工作线程,直到 GC 结束
- 对于单 CPU 的服务器表现很优秀,无线程交互的开销,专心做垃圾回收
Parallel Old 收集器
- Parallel Scanvenge 老年代版本
- 使用标记整理算法
- 多线程回收机制,适合计算类型项目,强调吞吐量
CMS 收集器
- 以获取最短回收停顿时间为目标
- 并发收集,低停顿,但是在并发的过程中可能会形成新的垃圾,也就是浮动垃圾
- 使用的是标记清理算法,会产生内存碎片
- 对 CPU 资源敏感,默认启动的回收线程是(CPU+3)/4,但在 CPU 数量 4 以上时,回收线程数量不小于 CPU 数量的 25%
G1、ZGC 回收器
G1(Garbage First)收集器 (标记-整理算法)
Java 堆并行收集器,G1 收集器是 JDK1.7 提供的一个新收集器,G1 收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1 收集器不同于之前的收集器的一个重要特点是:G1 回收的范围是整个 Java 堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。
ZGC (Z Garbage Collector)收集器
是一款由 Oracle 公司研发的,以低延迟为首要目标的一款垃圾收集器。它是基于动态 Region 内存布局,(暂时)不设年龄分代,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的收集器。在 JDK 11 新加入,还在实验阶段,主要特点是:回收 TB 级内存(最大 4T),停顿时间不超过 10ms。
优点: 低停顿,高吞吐量, ZGC 收集过程中额外耗费的内存小。
缺点: 浮动垃圾。
垃圾回收器间的配合使用图
各个垃圾回收器对比
CMS 收集器执行过程
CMS(Concurrent Mark Sweep,并发标记清除) 收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。
从名字就可以知道,CMS 是基于“标记-清除”算法实现的。CMS 回收过程分为以下四步:
初始标记 (CMS initial mark):主要是标记 GC Root 开始的下级(注:仅下一级)对象,这个过程会 STW,但是跟 GC Root 直接关联的下级对象不会很多,因此这个过程其实很快。
并发标记 (CMS concurrent mark):根据上一步的结果,继续向下标识所有关联的对象,直到这条链上的最尽头。这个过程是多线程的,虽然耗时理论上会比较长,但是其它工作线程并不会阻塞,没有 STW。
重新标记(CMS remark):顾名思义,就是要再标记一次。为啥还要再标记一次?因为第 2 步并没有阻塞其它工作线程,其它线程在标识过程中,很有可能会产生新的垃圾。
并发清除(CMS concurrent sweep):清除阶段是清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发进行的。
CMS 的问题
- 并发回收导致 CPU 资源紧张
在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低程序总吞吐量。CMS 默认启动的回收线程数是:(CPU 核数 + 3)/ 4,当 CPU 核数不足四个时,CMS 对用户程序的影响就可能变得很大。
- 无法清理浮动垃圾
在 CMS 的并发标记和并发清理阶段,用户线程还在继续运行,就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”。
- 并发失败(Concurrent Mode Failure)
由于在垃圾回收阶段用户线程还在并发运行,那就还需要预留足够的内存空间提供给用户线程使用,因此 CMS 不能像其他回收器那样等到老年代几乎完全被填满了再进行回收,必须预留一部分空间供并发回收时的程序运行使用。默认情况下,当老年代使用了 92% 的空间后就会触发 CMS 垃圾回收,这个值可以通过 -XX: CMSInitiatingOccupancyFraction 参数来设置。
这里会有一个风险:要是 CMS 运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:Stop The World,临时启用 Serial Old 来重新进行老年代的垃圾回收,这样一来停顿时间就很长了。
- 内存碎片问题
CMS 是一款基于“标记-清除”算法实现的回收器,这意味着回收结束时会有内存碎片产生。内存碎片过多时,将会给大对象分配带来麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。
为了解决这个问题,CMS 收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认开启),用于在 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的,这样停顿时间就会变长。还有另外一个参数 -XX:CMSFullGCsBeforeCompaction,这个参数的作用是要求 CMS 在执行过若干次不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。
CMS 收集器出现 Concurrent Mode Failure 的场景
CMS 在进行并发清理的时候,是与用户线程同步进行,如果这个时候从年轻代晋升到老年代或者创建的大对象直接进入老年代,导致老年代的内存不足,抛出 Concurrent Mode Failure 异常,就会触发 Serial Old,stop the world 单线程进行垃圾收集,Serial Old 采用的是标记整理的算法,在垃圾收集的同时,也会整理老年代的内存空间。从这些内容中总结触发 Concurrent Mode Failure 的可能如下:
- 在并发清理的时候,年轻代晋升到老年代或者大对象进入老年代的时候,内存担保机制失败,触发 Concurrent Mode Failure;
- CMS 在设置垃圾清理的上限值过高,导致 CMS 垃圾收集触发太晚,设置参数是
-XX:CMSInitiatingOccupancyFraction
,在 JDK5 的时候默认值是 68%,JDK7 及以后默认值是 92%,一般保持默认值即可; - 空间碎片太多,因为 CMS 采用的是标记清理算法,不会对内存进行整理,导致在年轻代晋升到老年代或者大对象进入老年代的时候无足够的连续内存空间,内存担保机制失败,触发 Concurrent Mode Failure;
- 垃圾产生的速度较快,年轻代的大多对象都是朝生夕死的,如果此时年轻代的空间较小,频繁的向老年代晋升对象,且晋升的对象会很快死亡,导致老年代回收压力大,产生的空间碎片也会增多,另一点就是年轻代空间小,大对象也会直接进入老年代,加剧了老年代的压力,容易触发 Concurrent Mode Failure。
触发 Concurrent Mode Failure,老年代垃圾收集器切换为 Serial Old,让停顿时间边长。
引用类型的分类
- 强引用: 只要存在强引用,GC 永远不会回收掉被引用的对象,类似 Object obj = new Object()这类的引用。
- 软引用: 用来描述有用但非必需的对象,发生内存溢出异常之前,会把软引用进行第二次回收,如果回收以后还没有足够的内存,才会抛出内存溢出异常。
- 弱引用: 用来描述有用但非必需的对象,只能生存到下一次 GC 之前,当 GC 工作时,无论当前内存是否足够,都会回收只被弱引用关联的对象,WeekReference 类可实现弱引用。
- 虚引用: 一个对象是否有虚引用存在,都对其生存时间没有影响,也无法通过一个虚引用来获取一个对象的实例,设置虚引用的目的是在这个对象被 GC 回收时收到一个系统通知,PhantomReference 类可实现虚引用。
方法区回收目标
只有发生 full GC 的时候才会对方法区进行回收操作,回收的目标如下:
- 废弃的常量
- 无用的类,同时满足以下四个条件即被定义为无用的类
- Java 堆中无该类的任何实例;
- 加载该类的 ClassLoader 已被回收;
- 该类的 java.lang.Class 对象没有被任何地方应用,无法通过反射访问该类的方法;
- 满足以上三个条件后,还需要看-Xnoclassgc 参数,如果是 true,表示不进行类卸载,也不会对类进行回收,默认是 false。
详细说一下 G1 的回收过程
G1(Garbage First)回收器采用面向局部收集的设计思路和基于 Region 的内存布局形式,是一款主要面向服务端应用的垃圾回收器。G1 设计初衷就是替换 CMS,成为一种全功能收集器。G1 在 JDK9 之后成为服务端模式下的默认垃圾回收器,取代了 Parallel Scavenge 加 Parallel Old 的默认组合,而 CMS 被声明为不推荐使用的垃圾回收器。G1 从整体来看是基于 标记-整理 算法实现的回收器,但从局部(两个 Region 之间)上看又是基于 标记-复制 算法实现的。
G1 回收过程,G1 回收器的运作过程大致可分为四个步骤:
初始标记(会 STW):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理在并发时有引用变动的对象。
最终标记(会 STW):对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。
清理阶段(会 STW):更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条回收器线程并行完成的。
JVM 中一次完整的 GC 是什么样子的
先描述一下 Java 堆内存划分。
在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old ),新生代默认占总空间的 1/3,老年代默认占 2/3。 新生代有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1。
新生代的垃圾回收(又称 Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。
老年代的垃圾回收(又称 Major GC)通常使用“标记-清理”或“标记-整理”算法。
再描述它们之间转化流程:
对象优先在 Eden 分配。当 eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
- 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区;
- Eden 区再次 GC,这时会采用复制算法,将 Eden 和 from 区一起清理,存活的对象会被复制到 to 区;
- 移动一次,对象年龄加 1,对象年龄大于一定阀值会直接移动到老年代。GC 年龄的阀值可以通过参数 -XX:MaxTenuringThreshold 设置,默认为 15;
- 动态对象年龄判定:Survivor 区相同年龄所有对象大小的总和 > (Survivor 区内存大小 * 这个目标使用率)时,大于或等于该年龄的对象直接进入老年代。其中这个使 用率通过 -XX:TargetSurvivorRatio 指定,默认为 50%;
- Survivor 区内存不足会发生担保分配,超过指定大小的对象可以直接进入老年代。
大对象直接进入老年代,大对象就是需要大量连续内存空间的对象(比如:字符串、数组),为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。
老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行 Full GC,Full GC 清理整个内存堆 – 包括年轻代、老年代和永久代。
Minor GC 和 Full GC 有什么不同
Minor GC: 只收集新生代的 GC。
Full GC: 收集整个堆,包括 新生代,老年代,永久代(在 JDK 1.8 及以后,永久代被移除,换为 metaspace 元空间)等所有部分的模式。
Minor GC 触发条件: 当 Eden 区满时,触发 Minor GC。
Full GC 触发条件:
通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存。如果发现统计数据说之前 Minor GC 的平均晋升大小比目前 old gen 剩余的空间大,则不会触发 Minor GC 而是转为触发 full GC。
老年代空间不够分配新的内存(或永久代空间不足,但只是 JDK1.7 有的,这也是用元空间来取代永久代的原因,可以减少 Full GC 的频率,减少 GC 负担,提升其效率)。
由 Eden 区、From Space 区向 To Space 区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
调用 System.gc 时,系统建议执行 Full GC,但是不必然执行。
介绍下空间分配担保原则
如果 YougGC 时新生代有大量对象存活下来,而 survivor 区放不下了,这时必须转移到老年代中,但这时发现老年代也放不下这些对象了,那怎么处理呢?其实 JVM 有一个老年代空间分配担保机制来保证对象能够进入老年代。
在执行每次 YoungGC 之前,JVM 会先检查老年代最大可用连续空间是否大于新生代所有对象的总大小。因为在极端情况下,可能新生代 YoungGC 后,所有对象都存活下来了,而 survivor 区又放不下,那可能所有对象都要进入老年代了。这个时候如果老年代的可用连续空间是大于新生代所有对象的总大小的,那就可以放心进行 YoungGC。但如果老年代的内存大小是小于新生代对象总大小的,那就有可能老年代空间不够放入新生代所有存活对象,这个时候 JVM 就会先检查 -XX:HandlePromotionFailure 参数是否允许担保失败,如果允许,就会判断老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 YoungGC,尽快这次 YoungGC 是有风险的。如果小于,或者 -XX:HandlePromotionFailure 参数不允许担保失败,这时就会进行一次 Full GC。
在允许担保失败并尝试进行 YoungGC 后,可能会出现三种情况:
- YoungGC 后,存活对象小于 survivor 大小,此时存活对象进入 survivor 区中
- YoungGC 后,存活对象大于 survivor 大小,但是小于老年大可用空间大小,此时直接进入老年代。
- YoungGC 后,存活对象大于 survivor 大小,也大于老年大可用空间大小,老年代也放不下这些对象了,此时就会发生“Handle Promotion Failure”,就触发了 Full GC。如果 Full GC 后,老年代还是没有足够的空间,此时就会发生 OOM 内存溢出了。
通过下图来了解空间分配担保原则:
什么是类加载?类加载的过程?
虚拟机把描述类的数据加载到内存里面,并对数据进行校验、解析和初始化,最终变成可以被虚拟机直接使用的 class 对象;
类的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7 个阶段。其中准备、验证、解析 3 个部分统称为连接(Linking)。如图所示:
加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定(也称为动态绑定或晚期绑定)
类加载过程如下:
加载,加载分为三步:
- 通过类的全限定性类名获取该类的二进制流;
- 将该二进制流的静态存储结构转为方法区的运行时数据结构;
- 在堆中为该类生成一个 class 对象;
验证:验证该 class 文件中的字节流信息复合虚拟机的要求,不会威胁到 jvm 的安全;
准备:为 class 对象的静态变量分配内存,初始化其初始值;
解析:该阶段主要完成符号引用转化成直接引用;
初始化:到了初始化阶段,才开始执行类中定义的 java 代码;初始化阶段是调用类构造器的过程。
什么是类加载器,常见的类加载器有哪些?
类加载器是指:通过一个类的全限定性类名获取该类的二进制字节流叫做类加载器;类加载器分为以下四种:
启动类加载器(BootStrapClassLoader):用来加载 java 核心类库,无法被 java 程序直接引用;
扩展类加载器(Extension ClassLoader):用来加载 java 的扩展库,java 的虚拟机实现会提供一个扩展库目录,该类加载器在扩展库目录里面查找并加载 java 类;
系统类加载器(AppClassLoader):它根据 java 的类路径来加载类,一般来说,java 应用的类都是通过它来加载的;
自定义类加载器:由 java 语言实现,继承自 ClassLoader。
什么是双亲委派模型?为什么需要双亲委派模型?
当一个类加载器收到一个类加载的请求,他首先不会尝试自己去加载,而是将这个请求委派给父类加载器去加载,只有父类加载器在自己的搜索范围类查找不到给类时,子加载器才会尝试自己去加载该类。
双亲委派模型是为了防止内存中出现多个相同的字节码,因为如果没有双亲委派的话,用户就可以自己定义一个 java.lang.String
类,那么就无法保证类的唯一性。
补充:那怎么打破双亲委派模型?
自定义类加载器,继承 ClassLoader
类,重写 loadClass
方法和 findClass
方法。
列举一些你知道的打破双亲委派机制的例子,为什么要打破?
JNDI:通过引入线程上下文类加载器,可以在 Thread.setContextClassLoader 方法设置,默认是应用程序类加载器,来加载 SPI 的代码。有了线程上下文类加载器,就可以完成父类加载器请求子类加载器完成类加载的行为。打破的原因,是为了 JNDI 服务的类加载器是启动器类加载,为了完成高级类加载器请求子类加载器(即上文中的线程上下文加载器)加载类。
Tomcat:应用的类加载器优先自行加载应用目录下的 class,并不是先委派给父加载器,加载不了才委派给父加载器。
tomcat 之所以造了一堆自己的 classloader,大致是出于下面三类目的:
- 对于各个
webapp
中的class
和lib
,需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况,而对于许多应用,需要有共享的 lib 以便不浪费资源。 - 与
jvm
一样的安全性问题。使用单独的classloader
去装载tomcat
自身的类库,以免其他恶意或无意的破坏; - 热部署。
- 对于各个
tomcat 类加载器如下图:
OSGi:实现模块化热部署,为每个模块都自定义了类加载器,需要更换模块时,模块与类加载器一起更换。其类加载的过程中,有平级的类加载器加载行为。打破的原因是为了实现模块热替换。
JDK 9:Extension ClassLoader 被 Platform ClassLoader 取代,当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。打破的原因,是为了添加模块化的特性。