4.1.1.JVM主要组成部分及其作用

JVM包含两个子系统和两个组件:两个子系统指的是类加载(Class loader)和执行引擎(Execution engine);两个组件包括:本地接口(Native Interface)和运行时数据区(Running data area)
- 类加载:根据类的全限定名类名来装载class文件到运行时数据区的方法区中,然后在堆内创建一个java.lang.Class对象用来封装方法区中的数据结构
- 执行引擎:执行classes中的指令
- 本地接口:与本地方法库交互,是与其他编程语言交互的接口
- 运行时数据区:包含堆、虚拟机栈、本地方法栈、程序计数器、方法区(元空间),其实就是常说的jvm内存
作用:编译器将java代码编译成class文件,当程序运行时,类加载器将编译后的class文件加载到内存中,然后再将他放到运行时数据区的方法区内。而字节码文件只是JVM提供的一套规范,不能直接交给底层操作系统去执行,需要特定的命令执行解释器—执行引擎将代码解释执行成操作系统可执行的二进制指令再交由CPU去执行,而这个过程需要调用其他语言的本地方法接口来实现整个功能。
4.1.2.JVM运行时数据区

JVM的运行时数据区主要包括:堆、方法区(元空间)、虚拟机栈、本地方法栈、程序计数器五个部分。
- 堆(Heap):堆是java虚拟机中内存最大的一部分,对象的实例以及数组的内存几乎都是在堆内进行分配的,堆是线程共享的一块区域,用来存放对象的实例,也是垃圾回收(GC)的主要区域;开启逃逸分析之后,某些未逃逸的对象可以通过标量替换的方式在栈中进行分配;堆细分又分为:新生代、老年代;对于新生代又分为:Eden区、Surviver1和Surviver2区;默认按照(8:1:1进行分配)
- 方法区:方法区也可以称为永久区,他储存的是Java虚拟机加载的类的信息、常量、静态变量等;JDK1.8取消了方法区这个概念,称为元空间;当应用中的Java类过多时,比如Spring等一些动态代理的框架生成很多类,如果占用空间超过我们的设定值,就会发生元空间益处。
- 虚拟机栈:虚拟机栈是线程私有的,他的生命周期和线程的生命周期是一致的。里面装的是一个一个的栈帧,每个方法在执行的时候,虚拟机栈都会为这个方法分配一个栈帧;栈帧中主要存放局部变量表、操作数栈、动态链接、返回地址等一些信息。在Java虚拟机规范中给这个区域分配了两种异常:如果线程栈的深度大于我们规定的深度,那么就会抛出StackOverflowError;如果虚拟机动态扩展时无法申请到足够的内存,就会抛出OutOfMemoryError;
- 局部变量表:局部变量表是一组变量值存储空间,用来存储方法的参数、方法内部的局部变量等,底层是变量槽;
- 操作数栈:是用来记录一个方法在执行的过程中,字节码指令向操作数栈中进行入栈和出栈的过程。大小在编译的时候已经确定了,当一个方法刚开始执行的时候,操作数栈中是空发的,在方法执行的过程中会有各种字节码指令往操作数栈中入栈和出栈。
- 动态链接:因为字节码文件中有很多符号的引用,这些符号引用一部分会在类加载的解析阶段或第一次使用的时候转化成直接引用,这种称为静态解析;另一部分会在运行期间转化为直接引用,称为动态链接。
- 返回地址(returnAddress):类型(指向了一条字节码指令的地址)JIT即时编译器(Just In Time Compiler),简称 JIT 编译器: 为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,比如锁粗化等
- 本地方法栈:本地方法栈和虚拟机栈类似,不同的是虚拟机栈服务的是Java方法,本地方法栈服务的是本地方法。在HotSpot虚拟机中是将本地方法栈和虚拟机栈合二为一的,他也会抛出StackOverflowError和OOM。
PC程序计数器:PC指的是执行下一条指令地址的指针,他是较小的一块区域,且是线程私有的。由于线程的切换,CPU在切换的时候都需要记住原线程的下一个指令的指针位置,所以每一个线程都有一个自己的PC;
4.1.3.堆内存分配策略及Minor Gc与Full Gc

对象优先分配在Eden区,如果Eden区没有足够的空间进行分配时,虚拟机执行一次MinorGC。而那些无需回收的存活对象,将会进到 Survivor 的 From 区(From 区内存不足时,直接进入 Old 区)。
- Minor GC 是指发生在新生代的GC,因为 Java 对象大多都是朝生夕死,所 有 Minor GC 非常频繁,一般回收速度也非常快;
- Major GC/Full GC 是指发生在老年代的 GC,出现了 Major GC 通常会伴 随至少一次 Minor GC。Major GC 的速度通常会比 Minor GC 慢 10 倍以上。
- 大对象直接进入老年代(需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
- 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄(Age Count)计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,直到达到阀值(默认15次),对象进入老年区。(动态对象年龄判定:程序从年龄最小的对象开始累加,如果累加的对象大小,大于幸存区的一半,则将当前的对象 age 作为新的阈值,年龄大于此阈值的对象则直接进入老年代)
每次进行Minor GC或者大对象直接进入老年区时,JVM会计算所需空间大小如小于老年区的剩余值大小,则进行一次Full GC。
4.1.4.深拷贝和浅拷贝
浅拷贝(shallowCopy)只是增加了一个指针指向已存在的内存地址;
- 深拷贝(deepCopy)是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存
使用深拷贝的情况下,释放内存的时候不会因为出现浅拷贝时释放同一个内存的错误。
- 浅复制:仅仅是指向被复制的内存地址,如果原地址发生改变,那么浅复制出来的对象也会相应的改变。
-
4.1.5.堆、栈的区别
物理地址
- 堆的物理地址分配对对象是不连续的。因此性能慢些。在GC的时候也要考虑到不连续的分配,所以有各种算法。比如:标记-消除,复制,标记-压缩,分代 (即新生代使用复制算法,老年代使用标记——压缩)
- 栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。
- 内存分别
- 堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。
- 栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。
- 存放的内容
- 堆存放的是对象的实例和数组。因此该区更关注的是数据的存储
- 栈存放:局部变量,操作数栈,返回结果。该区更关注的是程序方法的执行
- 程序的可见度
- 堆对于整个应用程序都是共享、可见的。
- 栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。
4.1.6.队列和栈的区别
队列和栈都是被用来预存储数据的。
- 操作的名称不同
- 队列的插入称为入队,队列的删除称为出队。
- 栈的插入称为进栈,栈的删除称为出栈。
- 可操作的方式不同
- 队列是在队尾入队,队头出队,即两边都可操作。
- 栈的进栈和出栈都是在栈顶进行的,无法对栈底直接进行操作。
- 操作方法不同
| 使用new关键字 | 调用了构造函数 |
|---|---|
| 使用class的newInstance方法 | 调用了构造函数 |
| 使用Constructor类的newInstance方法 | 调用了构造函数 |
| 使用clone方法 | 没有调用构造函数 |
| 使用反序列化 | 没有调用构造函数 |
步骤:类加载检查、内存分配、初始化零值、初始化对象头、执行init方法
- 类加载检查:虚拟机遇到 new 指令时,⾸先去检查是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。
- 内存分配:在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存,分配⽅式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。
- 指针碰撞:如果Java堆的内存是规整,即所有用过的内存放在一边,而空闲的的 放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小 相等的距离,这样便完成分配内存工作。
- 空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对 象,并在分配后更新列表记录。
- 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。
- 设置对象头:初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。
执⾏ init ⽅法:从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看, ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说(除循环依赖),执⾏ new 指令之后会接着执⾏⽅法,这样⼀个真正可⽤的对象才算产⽣出来。
4.1.8.对象创建并发问题
对象的创建在虚拟机中是一个非常频繁的行为,哪怕只是修改一个指针所指向的位置,在并发情况下也是不安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:
对分配内存空间的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的 原子性);
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆 中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配。只有 TLAB 用完并 分配新的 TLAB 时,才需要同步锁。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB。
4.1.9.对象访问定位
Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有句柄和直接指针两种方式。
指针: 指向对象,代表一个对象在内存中的起始地址。
句柄: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。指针访问:Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息,具体构造如下图所示:

优势:引用中存储的是稳定的句柄地址,在对象被移动时(垃圾收集时移动对象是非常普遍的行为),只会改变句柄中的实例指针,并不会改变引用
- 直接指针:引用中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。

优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非 常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用 的就是这种方式。
4.1.10.对象引用
- 普通的对象引用关系就是强引用。发生 gc 的时候不会被回收。
- 软引用:用于维护一些可有可无的对象。只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。
- 弱引用:对象相比软引用来说,要更加无用一些,它拥有更短的生命周期,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。
虚引用:是一种形同虚设的引用,在现实场景中用的不是很多,它主要用来跟踪对象被垃圾回收的活动。
4.1.11.内存泄露问题
内存泄漏是指不再被使用的对象或者变量一直被占据在内存中。理论上来说, Java是有GC垃圾回收机制的,也就是说,不再被使用的对象,会被GC自动回收掉,自动从内存中清除。 但是,即使这样,Java也还是存在着内存泄漏的情况,java导致内存泄露的原因很明确:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露, 尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景。
4.1.12.JVM类加载过程
过程:加载、验证、准备、解析、初始化
加载阶段
- 1.通过一个类的全限定名来获取定义此类的二进制字节流。
- 2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 3.在Java堆中生成一个代表这个类的java.lang.class对象,作为方法区这些数据的访问入口。
- 验证阶段
- 1.文件格式验证(是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理)
- 2.元数据验证(对字节码描述的信息进行语意分析,以保证其描述的信息符合Java语言规范要求)
- 3.字节码验证(保证被校验类的方法在运行时不会做出危害虚拟机安全的行为)
- 4.符号引用验证(虚拟机将符号引用转化为直接引用时,解析阶段中发生)
准备阶段
正式为类变量分配内存并设置类变量初始值的阶段。将对象初始化为“零”值
解析阶段:
虚拟机将常量池内的符号引用替换为直接引用的过程。
- 字符串常量池:堆上,默认class文件的静态常量池
- 运行时常量池:在方法区,属于元空间
- 初始化阶段:
执行类中定义的Java程序代码。
4.1.13.双亲委派机制
每⼀个类都有⼀个对应它的类加载器。系统中的 ClassLoder 在协同⼯作的时候会默认使⽤双亲委派模型 。即在类加载的时候,系统会⾸先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,⾸先会把该请求委派该⽗类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当⽗类加载器⽆法处理时,才由⾃⼰来处理。当⽗类加载器为null时,会使⽤启动类加载器 BootstrapClassLoader 作为⽗类加载器。

- 类加载器分类
- 启动类加载器(Bootstrap ClassLoader):虚拟机自身的一部分,用来加载 Java_HOME/lib/目录中的,或者被 -Xbootclasspath 参数所指定的路径中并且被虚拟机识别的类库;
- 扩展类加载器(Extension ClassLoader):负责加载\lib\ext目录或Java.ext.dirs系统变量指定的路径中的所有类库;
- 应用程序类加载器(Application ClassLoader)。负责加载用户类路径 (classpath)上的指定类库,我们可以直接使用这个类加载器。一般情况,如果我 们没有自定义类加载器默认就是用这个加载器。
- 双亲委派机制好处
- 沙箱安全机制:此机制保证JDK核心类的优先加载;使得Java程序的稳定运⾏,可以避免类的重复加载,也保证了 Java 的核⼼ API 不被篡改。
- 避免类重复加载:如果不⽤没有使⽤双亲委派模型,⽽是每个类加载器加载⾃⼰的话就会出现⼀些问题,⽐如我们编写⼀个称为 java.lang.Object 类的话,那么程序运⾏的时候,系统就会出现多个不同的Object 类。
- 如何打破双亲委派机制
- 先在本地cache查找该类是否已经加载过,看看 Tomcat 有没有加载过这个类。
- 如果Tomcat 没有加载过这个类,则从系统类加载器的cache中查找是否加载过。
- 如果没有加载过这个类,尝试用ExtClassLoader类加载器类加载,重点来了,这里并没有首先使用 AppClassLoader 来加载类。这个Tomcat 的 WebAPPClassLoader 违背了双亲委派机制,直接使用了 ExtClassLoader来加载类。这里注意 ExtClassLoader 双亲委派依然有效,ExtClassLoader 就会使用 Bootstrap ClassLoader 来对类进行加载,保证了 Jre 里面的核心类不会被重复加载。 比如在 Web 中加载一个 Object 类。WebAppClassLoader → ExtClassLoader → Bootstrap ClassLoader,这个加载链,就保证了 Object 不会被重复加载。
- 如果 BoostrapClassLoader,没有加载成功,就会调用自己的 findClass 方法由自己来对类进行加载,findClass 加载类的地址是自己本 web 应用下的 class。
- 加载依然失败,才使用 AppClassLoader 继续加载。
- 都没有加载成功的话,抛出异常。
总结一下以上步骤,WebAppClassLoader 加载类的时候,故意打破了JVM 双亲委派机制,绕开了 AppClassLoader,直接先使用ExtClassLoader 来加载类。
