JVM-深入理解java虚拟机

一、java内存区域

java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙。

  1. 运行时数据区:java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。

     
    1. 程序计数器
      1. 一块较小的内存空间,记录的是当前线程所正在执行的虚拟机字节码指令的地址(如果执行的是本地方法,值为空Undefined),线程私有,唯一没有OOM的区域。
    2. java虚拟机栈
      1. 线程私有,生命周期与线程相同。虚拟机栈描述的是java方法执行的线程内存模型:每个方法被执行的时候,java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法被调用直至完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
      2. 局部变量表:存放编译期可知的各种java虚拟机基本数据类型(boolean、byte、char、short、int、long、float、double)、对象引用(reference类型,并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄)和returnAddress类型(指向了一条字节码指令的地址)。这些数据类型在局部变量表中的存储空间以局部变量槽slot来表示,其中64位的long和double类型的数据会占用两个变量槽、其他数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配。
      3. 如果线程请求的栈深度大于虚拟机允许的深度-StackOveflowError;当栈扩展时无法申请足够的内存-OutOfMemoryError
    3. 本地方法栈
      1. 本地方法栈是为虚拟机使用到的本地方法服务
      2. 存在OOM/StackOverflow
    4. java堆
      1. 虚拟机管理的内存中最大的一块区域,被所有线程共享,在虚拟机启动时创建,该区域唯一的目的就是存放对象实例。几乎所有对象的实例以及数组都在堆上分配(逃逸分析技术、栈上分配、标量替换)。java堆是垃圾收集器管理的内存区域,也成为GC堆。
      2. 从内存分配的角度看,苏哦有线程共享的java堆中也可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配的效率。
      3. java堆可以处于物理上不连续的内存空间中,当在逻辑上是连续的。
      4. 可通过参数(-Xms、-Xmx)扩展,存在OOM
    5. 方法区
      1. 各个线程共享的内存区域,用来存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
      2. 1.7及以前:永久代实现方法区;1.8:本地内存的元空间实现方法区。(JDK7的HotSpot将原本放在永久代的字符串常量池、静态变量移至java堆中)
      3. 方法区无法满足新的内存分配需求时,将抛出OOM
    6. 运行时常量池
      1. 是方法区的一部分
      2. Class文件(也就是.java文件编译后生成的字节码.class文件)中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用()。这部分内容将在类加载后进入方法区的运行时常量池中存放。
      3. String::intern()是本地方法,作用是如果字符串常量池中已经存在,则返回常量池中的引用;否则,将此字符串添加到常量池中,并返回此对象的引用。
        1. 1.6中,将首次遇到的字符串实例复制到永久代(PermGen)中,并返回永久代中这个字符串实例的引用。如果一个字符串实例是由StringBuilder创建的,那么它将在Java堆上,因此它和由intern()返回的引用肯定不是同一个。
        2. 1.7中,符串常量池被从永久代移动到了Java堆中。当调用intern()方法时,返回的都是堆中对象的引用。
    7. 直接内存
  2. 对象的创建
    1. 类加载检查
    2. 内存分配
      1. 指针碰撞
      2. 空闲列表
    3. 初始化零值
    4. 对象头设置
    5. 执行init方法进行初始化
  3. 对象的内存布局
    1. 对象头
      1. mark word:存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等)

      2. 类型指针:确定该对象是哪个类的实例
    2. 实例数据:对象真正存储的有效信息
    3. 对齐填充:任何对象的大小都必须是8字节的整数倍(提升内存利用率、减少内存碎片)
  4. 对象的访问定位:java程序会通过栈上的reference数据来操作堆上的具体对象
    1. 使用句柄:堆中划分一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含对象示例数据(指向堆)和类型数据(指向方法区)各自具体的的地址信息;对象移动的时候只用改变句柄中的实例数据指针,而reference本身不需要修改;
    2. 直接指针:reference中存储的直接就是对象地址(hotSpot主要使用)。少一次指针定位的开销,访问速度快;

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

java运行时数据区中,程序计数器、虚拟机栈、本地方法栈这三个区域随线程而生,随线程而灭;而java堆和方法区有着显著的不确定性,这部分内存的分配和回收是动态的。

  1. 判断对象存活
    1. 引用计数法
    2. 可达性分析算法:从Java堆中所有的根对象(GC Roots)开始,遍历它们的引用关系,如果一个对象没有任何引用指向它,那么这个对象就是不可达的。
      1. 在虚拟机栈(栈帧这的本地变量表)中引用的对象
      2. 在方法区静态属性引用的变量
      3. 在方法区中常量引用的对象
      4. 在本地方法中中JNI引用的对象
      5. java虚拟机内部的引用,基本数据类对于的Class对象,一些常驻的异常对象
      6. 所有被synchronized持有的对象
  2. 引用
    1. 强引用,只要强引用关系存在,垃圾收集器就不会回收被引用的对象
      public static final Object STRONG_REF = new Object();
    2. 软引用,系统将要发生内存溢出前,会把这些对象列进回收范围之内进行第二次回收
    3. 弱引用,垃圾收集器开始工作,就会回收掉(ThreadLocal避免内存泄露)
    4. 虚引用,只为在这个对象被回收时收到一个系统通知
  3. 两次标记回收
    1. 可达性分析发现没有GC Roots相连接的引用链,进行第一次标记
    2. 筛选finalize()是否重写或被执行
      1. 加入F-Queue等待虚拟机创建线程执行;
      2. 若finalize()重新建立引用,该对象就逃逸了
      3. 注:任何一个对象的finalize()方法只会被调用一次
    3. 回收
  4. 方法区的回收
    1. 垃圾收集主要回收两部分内容:废弃的常量和不在使用的类型
      1. 判断类型是否不再使用(同时满足以下三条件)
        1. 该类所有的实例都已经被回收,在java堆中不存在该类及其任何子类的实例
        2. 加载该类的类加载器已经被回收
        3. 该类对应的Class对象没有在任何地方被引用
  5. 垃圾收集算法
    1. 分代收集算法(将java堆分为新生代和老年代)(当前商业虚拟机采用此算法)
      1. 新生代收集:Minor GC/Young Gc
      2. 老年代:Major GC/Old GC
      3. 整堆收集:Full GC
    2. 标记-清除算法
      1. 大量标记和大量清除效率低
      2. 产生不连续得到内存碎片
    3. 标记-复制算法
      1. 对象首先分配在Eden
      2. 空间不足,出发Minor GC,Eden和from存活的对象复制到to中,存活的对象年龄加一并且交换from和to(HotSpot默认Eden和Survivor的大小比例8:1)
      3. 当Survivor空间不足以容纳一次Minor GC之后存活的对象,这些对象通过分配担保机制直接进入老年代
    4. 标记-整理算法
      1. 其中移动存活对象需要暂停用户线程STW
  6. 垃圾收集器
    1. Serial
    2. ParNew
    3. Parallel Scavenge
    4. Serial Old
    5. Parallel Old
    6. CMS
    7. G1(Garbage First)
    8. ZGC
    9. Shenandoah
  7. 内存分配
    1. 自动内存管理最根本的目标:自动给对象分配内存以及自动回收分配给对象的内存。对象的内存分配,从概念上讲,应该都是在堆上分配(实际上也有可能经过即时编译被拆散为标量类型并间接地在栈上分配)。在经典分代的设计下,新生对象通常会分配至新生代,少数情况可能直接分配在老年代。
    2. 对象优先在Eden分配:当Eden区没有足够的空间进行分配时,将发起Minor GC;
    3. 大对象直接进入老年代:
    4. 长期存活的对象进入老年代:超过年龄阈值(-XX:MaxTenuringThreshold = 15),晋升至老年代
    5. 动态对象年龄判断:如果在Survivor空间中低于或等于某年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到年龄阈值。
    6. 空间分配担保:在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure)。-XX:HandlePromotionFailure=true代表允许担保失败;-XX:HandlePromotionFailure=false代表不允许担保失败。如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者 -XX:HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次Full GC。(避免频繁地触发垃圾回收,从而提高程序的运行效率)

三、类文件结构

四、类加载机制

虚拟机把描述一个类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成被java虚拟机使用的Java类型。这就是虚拟机的类加载机制。
Class文件由类加载器加载后,在JVM中将形成一份描述Class结构的元信息对象,通过该元信息可以获知Class的结构信息:如构造函数,属性和方法等,Java允许用户借由这个Class相关的元信息间接调用Class对象的功能。

  1. 类加载的时机
    1. 类的生命周期:加载、验证、准备、解析、初始化、使用和卸载,其中验证、准备、解析统称为连接
    2. 虚拟机没有对什么时候进行类的加载有强制约束,但是对于初始化阶段,虚拟机规范则是严格规定了以下情况必须立即对类进行初始化(加载、验证、准备自然得在初始化之前完成):
      1. 遇到new、getstatic、putstatic和invokestatic这四条字节码指令时,如果类没有进行过初始化,则需要触发其初始化(初始化自然存在类的加载)。这四条指令最常见的场景:使用new关键字实例化对象、获取或设置一个类的静态字段(被final修饰的除外)和使用一个类的静态方法时。
      2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果没有对类进行过初始化,则需先触发初始化。
      3. 当初始化类的时候,发现其父类还没有进行初始化,需先触发父类的初始化。
      4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个类。
      5. 当一个接口定义了JDK8新加入的默认方法,如果有这个接口的实现类发生初始化,那该接口要在其之前被初始化。
  2. 类的加载过程
    1. 加载(Loading):
      1. java虚拟机需要完成以下三件事
        1. 通过一个类的全限定名获取定义该类的二进制字节流。
        2. 将字节流代表的静态存储结构转换为方法区的运行时数据结构。
        3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
      2. 一个非数组类型的加载阶段,既可以使用虚拟机内置的启动类加载器来完成,也可以由用户自定义的类加载器去完成(即重写一个类加载器的loadClass方法)。对于数组类而言,情况有所不一样,数组类本身不通过类加载器创建,而是由虚拟机直接在内存中动态构建。数组的元素类型最终还是靠类加载器来完成加载。
      3. 加载阶段结束后,虚拟机外部的二进制字节流就存储在方法区中。在java堆中实例化一个Class类对象作为分许访问方法区的外部接口。
    2. 验证:验证是连接阶段的第一步,目的是保证Class文件的字节流包含的信息符合当前虚拟机的要求,保证输入的字节流能正确被解析并存储于方法区。
      1. 文件格式验证
      2. 元数据校验
      3. 字节码校验
      4. 符号引用校验
    3. 准备:正式为类中定义的变量(被static修饰的变量)分配内存并设置类变量初始值的阶段,这些内存在方法区(逻辑概念上)进行分配(JDK7及以后,类变量会随着Class对象一起存放在Java堆中)。还有,这里所说的初始值通常情况下是指数据类型的零值。
      1. 静态变量在什么时候赋值?
        若被final修饰,那么在准备阶段就会对这个静态变量赋值为定义的值。
        如果没有被final修饰,那么在准备阶段会赋零值,在初始化阶段真正赋为定义的值。
    4. 解析:虚拟机将常量池内的符号引用替换为直接引用的过程。
      1. 类或接口的解析
      2. 字段解析
      3. 方法解析
      4. 接口方法解析
    5. 初始化:初始化阶段是类加载过程的最后一步。在前面的类加载过程中,除了在加载阶段可以自动定义加载器参与类的加载过程外,其余的动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java代码。在准备阶段,变量已经被赋值为系统要求的零值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源。或者说初始化阶段是执行类构造器方法的过程。
  3. 类加载器:把类加载阶段的通过一个类的全限定名获取此类的二进制字节流这个动作放到Java虚拟机外部实现,以便让开发人员自己决定如何获取所需要的类,实现这个动作的代码称为“类加载器”。类加载器由加载、验证、准备、解析、初始化组成。
    1. 类与类加载器对于任意一个类,都需要加载它的类加载器和这个类本身一同确定其所在虚拟机的唯一性。通俗地说,比较两个类是否相等,只有在相同的类加载器的前提下才有意义,否则,即使这两个类来自于同一个Class文件,被同一个虚拟机加载,只要类加载器不一样,这两个类就不可能相等。
    2. 双亲委派模型:从虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器,是虚拟机的一部分;另一种是其他的类加载器,独立于虚拟机之外,而且全都继承于抽象类java.lang.ClassLoader。
      从开发人员的角度来看,绝大部分java程序都会使用到以下3种系统提供的类加载器:
      1. 启动类加载器:这个类负责将放在\lib目录下的并且被虚拟机识别的(按照文件名识别,名字不符合的类库即使放在lib目录下也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被java程序直接引用。
      2. 扩展类加载器:它负责加载\lib\ext目录下的所有类库,开发者可以直接使用拓展类加载器.
      3. 应用程序类加载器:它负责加载用户类路径(ClassPath)下所指定的类库,开发者可以直接使用。如果程序中没有自定义自己的类加载器,一般情况下这个就是程序默认的类加载器。
    3. 双亲委派的过程过程是:如果一个类加载器收到类加载请求,首先不会自己尝试加载这个类而是委托给自己的父类去加载,父类又委托给自己的父类。因此所有的类加载都会委托给顶层的父类,即Bootstrap Classloader进行加载,然后父类自己无法完成这个加载请求,子加载器才会尝试自己去加载。使用双亲委派模型,Java类随着它的加载器一起具备了一种带有优先级的层次关系,通过这种层次模型,可以避免类的重复加载,也可以避免核心类被不同的类加载器加载到内存中造成冲突和混乱,从而保证了Java核心库的安全。
    4. 破坏双亲委派模型
      1. 因为双亲委派过程都是在loadClass方法中实现的,那么想要破坏这种机制,那么就自定义一个类加载器,重写其中的loadClass方法,使其不进行双亲委派即可。
      2. SPI
      3. Tomcat

五、字节码执行引擎

六、java内存模型与线程

七、线程安全与锁优化

你可能感兴趣的:(JVM,java,jvm)