深入理解Java虚拟机

时间:2023-01-02 13:23:56

Java内存区域与内存溢出异常

运行时数据区域

程序计数器

线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值为空。

Java虚拟机栈

Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型。每一个方法从调用直至执行完成的工程就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

这个区域定义了两种异常情况:如果线程请求的栈的深度大于虚拟机所允许的深度,将抛出*Error异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常

本地方法栈

本地方法栈为虚拟机使用到的Native方法服务

Java堆

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

Java堆是垃圾收集器管理的主要区域。

方法区

方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

运行时常量池

运行时常量池是方法区的一部分。用于存放编译器生成的各种字面量和符号引用。 一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

HotSpot虚拟机对象探秘

对象的创建

虚拟机遇到一条new指定时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果有,那必须先执行相应的类加载过程。

对象的内存布局

HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

对象的访问定位

我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。

目前主流的访问方式有使用句柄和指定指针两种:
* 如果使用直接指针访问,reference中存储的直接就是对象地址。
* 如果使用句柄访问,reference中存储的就是对象的句柄地址。 而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

使用句柄来访问的最大好处就是reference中存储的时稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销。

垃圾收集器与内存分配策略

一个接口中的多个实现类需要的内存可能都不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序运行期间时才会创建哪些对象,这部分内存的分配和回收时动态的,垃圾收集器所关注的时这部分内存。

对象已死吗

垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还存活着那些已经死去。

引用计数算法

给对象添加一个引用计数器。任何时刻计数器为0的对对象就是不可能再被使用的。

可达性分析算法

通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用相连时,则证明此对象时不可用的。

可作为GC Roots的对象包括:
* 虚拟机栈(栈帧中的本地变量表)中的引用的对象。
* 方法区中类静态属性引用的对象。
* 方法区中常量引用的对象。
* 本地方法栈中JNI引用的对象。

再谈引用

  • 强引用:类似 Object obj = new Object()这类的引用
  • 软引用:描述一些还有用但非必须的对象。对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。
  • 弱引用:被引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作室,无论当前内存是否足够,都会回收掉只被引用关联的对象。
  • 虚引用:最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响。

垃圾回收算法

标记-清除算法

首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
不足:* 标记和清除两个过程的效率都不高
* 标记清除之后会产生大量不连续的内存碎片

复制算法

将可用内存的容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

新生代一般用这种算法,内存分为一块较大的Eden空间和两块较小的Survivor空间。当回收时,将Eden和Survivor中还存活的对象一次性地复制到另一个Suvivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

标记-整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率会变低。如果不想浪费50%的空间就需要有额外的空间进行分配担保,应对对象100%存货的情况,老年代一般不能直接选用这种算法。

标记-整理算法,标记过需要回收的对象,然后让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

一般把Java堆分为新生代和老年,新生代中,每次大批对象死亡,选用复制算法。老年代对象存活率高,使用标记整理或标记清理。

垃圾收集器

  • Serial收集器是一个单线程的收集器。进行垃圾收集时,必须暂停其他所有的工作线程,直到收集结束。 优点:简单而高效
  • ParNew收集器其实就是Serial收集器的多线程版本。除了Serial收集器,只有它能与CMS收集器配合工作。ParNew收集器在单CPU环境中绝对不会有比Serial收集器更好的效果。
  • Parallel Scavenge收集器 关注点和其他收集器不同。CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器目标是达到一个可控制的吞吐量。
  • Serial收集器时Serial收集器的老年代版本,使用标记-整理算法。
  • Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程的标记-整理算法
  • CMS收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于标记-清除算法实现。分为四个步骤:初始标记->并发标记->重新标记->并发清除
    缺点:对CPU资源敏感。 无法处理浮动垃圾。会产生空间碎片。
  • G1收集器能够充分利用多CPU、多核环境的优势。分代收集。基于标记-整理算法。建立可预测停顿。G1收集器优先回收价值组大的Region。

长期存活的对象将进入老年代

每个对象定义了一个对象年龄计数器。对象在Eden出生经过一次Minor GC后仍然存活,并且能被Survivor容纳,将被移动到Survivor空间中,并且对象年龄设为1。每熬过一次MinorGC就增加1岁,增加到一定程度,就将会晋升到老年代中。

虚拟机类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制

类加载的时机

生命周期:加载、验证、准备、解析、初始化、使用、卸载。验证、准备、解析统称为连接

5种情况必须立即对类进行初始化:
* 遇到 new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。
* 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
* 初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
* 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类。
* 如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个句柄所对应的类没有进行过初始化,则需要先触发其初始化。

所有引用类的方式都不会触发初始化
* 子类引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
* 通过数组定义来引用类,不会触发此类的初始化。
* 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量类的初始化。

接口在初始化时,并不要求其父接口全部都完成了初始化。

类加载的过程

加载

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。

加载阶段与连接阶段的部分内容是交叉进行的。

验证

这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

包括:
* 文件格式验证:字节流是否符合Class文件格式的规范
* 元数据验证:对字节码描述的信息进行语义分析,保证符合Java语言规范的要求
* 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
* 符号引用验证:发生在将符号引用转化为直接引用的时候,符号引用验证可以看作是对类自身以外的信息进行匹配性校验。。

准备

准备阶段是正式为类变量分配内并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象实例化时随着对象分配在Java堆中。

解析

解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。

初始化

初始化阶段是执行类构造器()方法的过程。

()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。

虚拟机会保证子类的()方法执行之前,父类的()方法已经执行完毕。由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于类的变量赋值操作。
如果一个类中没有静态语句快,也没有对变量赋值的操作,那么编译器可以不为这个类生成()方法。多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法。、

类加载器

类与类加载器

比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类必定不相等

双亲委派模型

  • 启动类加载器:将存放在\lib目录中的或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中。
  • 扩展类加载器:这个加载器由sun.misc.Launcher$ExtClassLoader实现,负责加载\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中的所有类库。
  • 应用程序类加载器:负责加载用户类路径上所指定的类库,一般情况下就是程序中默认的类加载器。

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器

双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己加载。

实现:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

破坏双亲委派模型

JDK1.2以后不再提倡覆盖loadClass()方法,而应该把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。

Java内存模型与线程

Java内存模型

主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则。
Java内存模型规定了所有的变量都存储在主内存中。每个线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写内存中的变量,不同线程之间也无法直接访问对方工作内存中的变量。
深入理解Java虚拟机

内存间交互操作

Java内存中定义了一下8中操作来完成:
* lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
* unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
* read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
* load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
* use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
* assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
* store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
* write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

对于volatile型变量的特殊规则

当一个变量定义为volatile之后,它将具备两种特性:保证此变量对所有线程的可见性,这里的可见性是指一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。

volatile不符合以下场景时需要加锁:
* 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
* 变量不需要与其他的状态变量共同参与不变的约束。

使用volatile变量的第二个语义是禁止指令重排序优化。

先行发生原则

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。