1. JVM简介
JVM 是Java Virtual Machine的简称,意为Java虚拟机。
虚拟机额是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。
常见的虚拟机:JVM、VMwave、Virtual Box。
JVM和其他的两个虚拟机的区别:
1. VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
2. JVM是通过软件模拟Java字节码的指令集,JVM只是主要保留了PC寄存器,其他的寄存器都进行了裁剪。
JVM是一台被制定过的现实当中不存在的计算机。
2. JVM的运行流程
2.1 JVM执行流程
程序在执行之前先要把Java代码转换成字节码文件,JVM首先需要把字节码通过一定的方式类加载器,把文件加载到内存中运行时数据区,而字节码文件时JVM的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器**执行引擎(Execution Engine)**将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调用其他语言的接口本地库接口(Native Interface)来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。
总结来看,JVM主要通过分为以下4个部分,来执行Java程序的:
1. 类加载器
2. 运行时数据区
3. 执行引擎
4. 本地库接口
3. JVM运行时数据区
JVM运行时数据区域也叫内存布局,但需要注意的是它和Java内存模型完全不同,他由以下5大部分组成:
3.1 堆(线程共享)
堆的作用:程序中创建的所有对象都保存在堆中。
堆里面分为两个区域新生代和老生代,新生代放新建的对象,当经过一定GC次数之后还存活的对象会放入老生代。新生代还有3个区域::⼀个Endn+两个Survivor(S0/S1)。
垃圾回收的时候会将Endn中存活的对象放到一个未使用的Survivor中,并把当前的Endn和正在使用的Survivor清除掉。
3.2 Java虚拟机栈(线程私有)
Java虚拟机栈的作用:Java虚拟机栈的生命周期和线程相同,Java虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。
Java虚拟机栈包含了以下4部分:
1.局部变量表:存放了编译器可知的各种基本数据类型、对象引用。局部变量表所需的内存空间在编译期间完成分配,并进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表大小。简单来说,就是存放方法参数和局部变量。
2. 操作栈:每个方法会生成一个先进后出的操作栈。
3. 动态链接:指向运行时常量池的方法引用。
4. 方法返回地址:PC寄存器的地址。
什么是线程私有?
由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时刻,一个处理器都只会执行一条程序中的指令。因此为了切换线程后能恢复到正确的执行位置,每条线程都需要独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们就把类似这类区域称为"线程私有"的内存。
3.3 本地方法栈(线程私有)
本地方法栈和虚拟机栈类似,只不过Java虚拟机栈是给JVM使用的,而本地方法栈是给本地方法使用的。
3.4 程序计数器(线程私有)
程序计数器的作用:用来记录当前线程执行的行号。
程序计数器是一块比较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。
如果当前线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地 址;如果正在执行的是一个Native方法,这个计数器值为空。
3.5 方法区(线程共享)
方法区:用来存储被虚拟机加载的类信息、常量、静态变量、编译器编译后的代码等数据。
小结:
3.6 内存布局中的异常问题
1. Java堆溢出
Java堆用于存储对象实例,只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免来 GC清除这些对象,那么在对象数量达到最大堆容量后就会产生内存溢出异常。
内存泄漏:泄露对象无法被GC
内存溢出:内存对象确实还应该存活。此时要根据JVM堆参数与物理内存相比较检查是否还应该把JVM 堆内存调大;或者检查对象的生命周期是否过长。
2. 虚拟机栈和本地方法栈溢出
会产生两种异常:
1. 如果线程请求的栈的深度大于虚拟机所允许的最大深度,会抛出StackOverFlowError异常。
2. 如果虚拟机在拓展栈时无法申请到足够的内存空间,则会抛出OOM异常。
出现StackOverFlowError异常时有错误堆栈可以阅读,比较好找问题所在。如果使用虚拟机默认参数,堆深度在多数情况下达到1000-2000完全没问题,对于正常的方法调用,完全够用。
如果是因为多线程导致内存溢出的问题,再不能减少线程数的情况下,只能减少最大堆和减少栈容量的方式来换取更多线程。
4. JVM类加载
类加载过程
对于一个类来说,它的生命周期是这样的:
1. 加载
加载阶段,Java虚拟机需要完成以下三件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法去的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。
2. 验证
文件格式验证、字节码验证、符号引用验证。
3. 准备
准备阶段是正式为类中定义的变量分配内存并设置类变量初始值的阶段。
4. 解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。
双亲委派模型
如果一个类加载器收到了类加载的请求,它首先不会自己区尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
优点:
避免重复加载类:比如A类和B类都有一个父类C,那么A启动时会将C类加载起来,那么B类在进行加载时就不需要在重复加载C类了。
安全性:使用双亲委派模型也可以保证了Java的核心API不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为java.lang.Object类的话,那么程序运行的时候,系统就会出现多个不同的Object类,而有些Object类又是用户自己提供的因此安全性就不能得到保证了。
破坏双亲委派模型
双亲委派模型虽然有其优点,但在某些情况下也存在一定的问题,比如Java中SPI(Service Provider Interface,服务提供接口)机制中的JDBC实现。
JDBC的Driver接口定义在JDK中,其实现由各个数据库的服务商来提供,比如MySQL驱动包。我 们先来看下JDBC的核心使用代码:
public class JdbcTest { public static void main(String[] args){ Connection connection = null; try { connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:330 } catch (SQLException e) { e.printStackTrace(); } System.out.println(connection.getClass().getClassLoader()); System.out.println(Thread.currentThread().getContextClassLoader()); System.out.println(Connection.class.getClassLoader()); } }然后我们进入DriverManager的源码类就会发现它是存在系统rt.jar中的,如下图所示:
由双亲委派模型的加载流程可知rt.jar是有顶级父类Bootstrap ClassLoader加载的,如下图所示:
而当我们进入它的getConnection源码时却发现,它在调用具体的类实现时,使用的是子类加载器 (线程上下文加载器Thread.currentThread().getContextClassLoader)来加载具体的数据库数据库 包(如mysql的jar包),源码如下:
@CallerSensitive public static Connection getConnection(String url, java.util.Properties info) throws SQLException { return (getConnection(url, info, Reflection.getCallerClass())); } private static Connection getConnection( String url, java.util.Properties info, Class<?> caller) throws SQLExcept ClassLoader callerCL = caller != null ? caller.getClassLoader() : null; synchronized(DriverManager.class) { // synchronize loading of the correct classloader. if (callerCL == null) { //获取线程上下为类加载器 callerCL = Thread.currentThread().getContextClassLoader(); } } if(url == null) { throw new SQLException("The url cannot be null", "08001"); } println("DriverManager.getConnection(\"" + url + "\")"); SQLException reason = null; for(DriverInfo aDriver : registeredDrivers) { // isDriverAllowed 对于 mysql 连接 jar 进⾏加载 if(isDriverAllowed(aDriver.driver, callerCL)) { try { println(" trying " + aDriver.driver.getClass().getName()) Connection con = aDriver.driver.connect(url, info); if (con != null) { // Success! println("getConnection returning " + aDriver.driver.getC return (con); } } catch (SQLException ex) { if (reason == null) { reason = ex; } } } else { println(" skipping: " + aDriver.getClass().getName()); } } if (reason != null) { println("getConnection failed: " + reason); throw reason; } println("getConnection: no suitable driver found for "+ url); throw new SQLException("No suitable driver found for "+ url, "08001"); }这样一来就破坏了双亲委派模型,因为DriverManager位于rt.jar包,由BootStrap类加载器加载, 而其Driver接口的实现类是位于服务商提供的Jar包中,是由子类加载器(线程上下文加载器Thread.currentThread().getContextClassLoader)来加载的,这样就破坏了双亲委派模型了(双亲 委派模型讲的是所有类都应该交给父类来加载,但JDBC显然并不能这样实现)。
5. 垃圾回收相关
Java堆中存放着几乎所有的对象实例,垃圾回收器在对堆进行垃圾回收钱,首先要判断这些对象哪些还存活,哪些已经"死去"。
内存VS对象
在Java中,所有的对象都是要存在内存中的,因此我们将内存回收,也可以叫做死亡对象回收。
①死亡对象的判断算法
a)引用计数法
引用计数描述的算法为:
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"。
b)可达性分析
通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路称为"引用链",当一个对象到GC Roots没有任何的引用链相连时,证明此对象是不可用的。
在Java中,可作为GD Roots的对象包含下面几种:
1. 虚拟机栈中引用的对象
2. 方法区中类静态属性引用的对象
3. 方法区中常量引用的对象
4. 本地方法栈中Native方法引用的对象
引用可以分为下面四种:
1. 强引用:强引用指的是在程序代码之中普遍存在的,类似于"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例。
2. 软引用:软引用是用来描述⼀些还有用但是不是必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。
3. 弱引用:弱引用也是用来描述非必需对象的。但是它的强度要弱于软引用。被弱引用关联的对象只能生存到下一次垃圾回收发生之前。当垃圾回收器开始进行工作时,无论当前内容是否够用,都会回收掉只被弱引用关联的对象。
4. 虚引用:虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
②垃圾回收算法
a)标记-清除算法
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
标记-清除算法有两个不足:
1. 效率问题:标记和清除这两个过程的效率都不高
2. 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。
b)复制算法
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面, 然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。算法的执行流程如下图:
c)标记-整理算法
复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。 针对老年代的特点,提出了一种称之为"标记-整理算法"。标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。流程图如下:
d)分代算法
分代算法和上面讲的3种算法不同,分代算法是通过区域划分,实现不同区域和不同的垃圾回收策 略,从而实现更好的垃圾回收。
当前JVM垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想, 只是根据对象存活周期的不同将内存划分为几块。⼀般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法。
新生代:一般创建的对象都会进入新生代;
老年代:大对象和经历了N次(一般情况默认是15次)垃圾回收依然存活下来的对象会从新生代移动到老年代。
Minor GC:新生代GC,指发生在新生代的垃圾收集。
Full GC:老年代GC,指发生在老年代的垃圾收集。
③总结:一个对象的一生
一个对象的一生:我是一个普通的 Java 对象,我出生在 Eden 区。在 Eden 区,我还看到和我长得很像的小兄弟,我们在 Eden 区中玩了挺长时间。有一天,Eden 区中的人实在是太多了,我就被迫去了 Survivor 区的 “From” 区(S0 区)。自从去了 Survivor 区,我就开始漂了,有时候在 Survivor 的 “From” 区,有时候在 Survivor 的 “To” 区(S1 区),居无定所。直到我 18 岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了老年代那边。老年代里,人很多,并且年龄都挺大的,我在这里也认识了很多⼈。在老年代里,我生活了很多年(每次 GC 加一岁),然后被回收了。
6. JVM
JVM定义了一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在在各种平台下都能达到一致的内存访问效果。
①主内存与工作内存
Java内存模型的主要目标时是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后两者是线程私有的,不会被线程共享。
Java内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
②内存间交互操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存中拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了如下8种操作来完成。JVM实现时必须保证下面提及的每⼀种操作的原子的、不可再分的。
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。
store(存储):作用于工作内存的 variable,它把工作内存中一个变量的值传送到主内存中,以便后续的 write 操作使用。
write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
Java内存模型的三大特性:
原子性:由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write。大致可以认为,基本数据类型的访问读写是具备原子性的。如若需要更大范围的原子性,需要 synchronized 关键字约束。(即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。)
可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。volatile、synchronized、final 三个关键字可以实现可见性。
有序性:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行”,后半句是指“指令重排序”和“工作内存与主内存同步延迟”现象。
③volatile型变量的特殊规则
1. 保证此变量对所有线程的可见性。
2. 使用volatile变量的语义是禁止指令重排序。