标签: java jvm

Jvm体系结构

JVM体系主要是两个JVM的内部体系结构,分为三个子系统和两大组件,分别是:

  • 类装载器(ClassLoader)子系统、执行引擎子系统和GC子系统
  • 组件是内存运行数据区域本地接口
    详细的结构: (图片来源以及详细解释)

https://zhuanlan.zhihu.com/p/28347393
JVM虚拟机 - 图1

运行时数据区总体概括

运行时数据区:

  • 经过编译生成的字节码文件(class文件),由 class loader(类加载子系统)加载后交给执行引擎执行。
  • 在执行引擎执行的过程中产生的数据会存储在一块内存区域。这块内存区域就是运行时区域。

运行时数据区总体框架图:

JVM虚拟机 - 图2

总体概括

  • 堆: 存放Java 对象, 线程之间共享的;
  • 栈: 方法运行,每一个方法对应一个栈帧,每一个线程对应一个栈;每个栈由包括 操作数、局部变量表、指向运行时常量池的引用,方法返回地址、附加位区;所以是线程不共享 (这个就是栈的宽度)(而栈的深度就是栈帧的个数);
  • 方法区 (静态区) : 被虚拟机加载的类信息、静态 (static) 变量,常量 (final) ,即时编译器编译后的代码等数据。运行常量池是方法区的一部分,class文件除了有类的版本、字段、接口、方法等描述信息之外,还有一项信息常量池保存缩译期生成的字面量和符号引用。 线程之间共享的。
  • 程序计数器: 指出某一个时候执行某一个指令、执行完毕之后要返回的位置,当执行的Java方法的时候,这里保存的当前执行的地址,如果执行的是本地方法,那么程序计数器为空。线程不共享

划分线程共享和线程独占和线程共享的原因:

先熟悉一下一个一般性的 Java 程序的工作过程。

  • 一个 Java 源程序文件,会被编译为字节码文件(以 class 为扩展名) ,每个java程序都需要运行在自己的JVM上;
  • 然后告知 JVM 程序的运行入口,再被 JVM 通过字节码解释器加载运行。那么程序开始运行后,都是如何涉及到各内存区域的呢?
  • 概括地说来,JVM初始运行的时候都会分配好 Method Area (方法区) 和Heap (堆) ,而JVM 每遇到一个线程,就为其分配一个 Program Counter Register (程序计数器) ,VM Stack (虚拟机栈) 和Native Method Stack (本地方法栈) , 当线程终止时,三者(虚拟机栈,本地方法栈和程序计数器) 所占用的内存宝间也会被释放掉。这也是为什么我们把内存区域分为线程共享和非线程共享的原因,非线程共享的那三个区域的生命周周期与所属线程相同,而线程共享的区域与JAVA程序运行的生命周期相同,所以这也是系统垃圾回收的场所只发生在线程共享的区域 (实际上对大部分虚拟机来说发生在Heap上) 的原因。

    Java中对象的创建过程?

    对象创建过程流程图:
    JVM虚拟机 - 图3
  1. 类加载检查

首先是代码中new关键字在编译后,会生成一条字节码new指令,当虚拟机遇到一条字节码new指令时,会根据类名去方法区运行时常量池找类的符号引用,检查符号引用代表的类是否已经加载,解析和初始化过。如果没有就执行相应的类加载过程。

  1. 分配内存

虚拟机从Java堆中分配一块大小确定的内存(因类加载时,创建一个类的对象的所需内存的内存大小就确定了),并且初始化为零值。内存分配的方式有指针碰撞和空闲列表两种,取决于虚拟机采用的垃圾回收期是否带有空间压缩整理的功能。

  1. 对象初始化(虚拟机层面)

虚拟机会对对象进行必要的设置,讲对象的一些信息存储在Object header中

  1. 对象初始化(Java程序层面)

在构造一个类的实例对象是,遵循的原则是先静后动,先父后子,先变量,后代码块,构造器。在Java程序层面会依次进行以下操作:

  • 初始化父类的静态变量(如果是首次使用此类)
  • 初始化子类的静态变量(如果是首次使用此类)
  • 执行父类的静态代码块(如果是首次使用此类)
  • 执行子类的静态代码块(如果是首次使用此类)
  • 初始化父类的实例变量
  • 初始化子类的实例变量
  • 执行父类的普通代码块
  • 执行子类的普通代码块
  • 执行父类的构造器
  • 执行子类的构造器

    如何解决内存分配是的多线程并发竞争问题?

    内存分配不是一个线程安全的操作,在多个线程进行内存分配时,可能会存在数据不同步的问题。所以有以下两种解决方案:

  • 添加CAS锁:对内存分配的操作进行同步处理,添加CAS锁,配上失败重试的方式保证原子性。(默认使用这种方式)

  • 预先给各线程分配TLAB:预先在Java堆中给各个线程分配一块TLAB(本地线程缓冲区)内存,每个线程现在各自的缓冲区中分配内存,使用完了再通过第一种添加CAS锁的方式分配内存(是否启动取决于-XX: +/-UseTLAB参数)。

    Java对象的内存布局是怎么样的?

    对象在内存中存储布局主要分为对象头,实例数据和对齐填充。
    JVM虚拟机 - 图4
    对象头
    对象头主要包括对象自身的运行时数据(Mark Word),类型指针(Class Pointer)。如果对象是数组,还需要包含数组长度(否则无法确定数组对象大小)。

    Mark Word:存储对象自身运行时数据,例如hashCode,GC分代年龄,锁状态标志,线程持有的锁等等。在32位系统中占4字节,在64位系统中,占8字节。
    JVM虚拟机 - 图5
    Class Pointer:用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统占8字节。
    Length:如果是数组对象,还保存数组长度的空间,占4字节。
    实例数据
    保存对象的非静态成员变量数据。实例数据存储的是真正有效数据,即各个字段的值。无论是子类中定义的,还是从父类继承下来的都需要记录。这部分数据的存储顺序受到虚拟机的分配策略以及字段在类中的定义顺序的影响。
    对齐填充
    因为HotSpot虚拟机的自动内存管理系统要求起始地址是8字节的整数倍,所以任何对象的大小必须是8字节的整数倍。而对象头部分一般是8字节的倍数,如果实力数据部分不是8字节的整数倍需要对齐填充来补全。

Java内存区域

  1. 程序计数器
    是一块较小的内存空间,可以把它当作当前线程所执行的字节码的行号指示器。
  2. Java虚拟机栈
    Java虚拟机栈是线程私有的,生命周期同线程一样。它描述的是Java方法执行的内存模型。
    该区域出现异常,出现的报错有两种:
    一、线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
    二、如果虚拟机栈可以动态扩展,但是扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常;
  3. 本地方法栈
    本地方法栈与虚拟机栈类似,之间区别在于虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务。本地方法栈出现异常也会抛出上面的两个错误(StackOverflowError异常和OutOfMemoryError异常)。
  4. Java堆
    Java堆是Java虚拟机所管理的内存中最大的一块,用来存放对象实例。Java堆是垃圾收集器管理的主要区域,则该区域还被称为GC堆。Java堆被分为:新生代和老年代(Eden空间、From Survivor空间、To Survivor空间等)。
    Java堆得大小设置参数:-Xmx和-Xms。这两个值分别是堆的最大值和初始值。
  5. 方法区(Non-Heap非堆)
    方法区与堆一样,均是各个线程共享的内存区域,方法区主要用于存储已被虚拟机加载的类信息、常亮、静态变量、即时编译后的代码等数据。
    其中类信息包括: 类的版本,字段,方法,接口等;
    方法区 != 永久代,HotSpot使用永久代实现方法区,这样HotSpot的垃圾收集器可以像管理Java推一样来管理这块内存区域,省去专门为方法区编写内存管理代码的工作;
  • 根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。

JDK6时,String等常量信息至于方法区,JDK7时,已经移动到堆;

Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的印载,一般来说,这个区域的回收“成绩”比较难以令人满意,尤其是类型的印载,条件相当苛刻,但是这部分区域的回收确实是必要的。在 Sun 公司的 BUG 列表中,曾出现过的若干个严重的 BUG 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存汇漏。

关于元空间: 元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间和永久代之间最大的差别在于: 元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

  1. 运行时常量池
    运行常量池是方法区的一部分。class文件中除了有类的的版本、字段、方法、接口等描述信息外,还有一项是常量池,用于存放编译期生成的各种字面量和符号引用,这部分在类加载后进入方法区的运行时常量池中存放。

虚拟机中的对象

  1. 对象的内存布局> 对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
    对象头:包含两部分,一、用于存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等);二、类型指针,即对象只想他的类元数据的指针

垃圾回收和内存分配策略

对象存活判定算法

引用计数器算法(jvm中没有使用该算法)

可以记录每个对象被引用的数量来确定,当被引用的数量为0时,代表可以回收。

  1. 概述
    给对象中添加一个引用计数器,每当有一个地方引用它是,计算数值就加1,;当引用失效时,计数器值就减1;任何时刻数值为0的对象就是不可能再被使用。
    可达性分析算法
    判断对象的引用链是否可达来确定对象是否可以回收。就是把对象之间的引用关系看成一个图,通过从一些GC Roots对象作为起点,根据这些对象的引用关系一直向下搜索,走过的路径就是引用链,当所有的GC Roots对象的引用链都到达不了这个对象,就说明这个对象不可达,可以回收。GC Roots对象一般是当前肯定不会被回收的对象,一般是虚拟机栈中局部变量表中的对象,方法区的类静态变量引用的对象,方法区常量引用的对象,本地方法栈中Native方法引用的对象。

    垃圾回收算法?

    标记-清除算法(最基础的算法,后续的算法都是基于此改进)
    使用:一般用于老年代
    使用这种算法的垃圾收集器:CMS垃圾收集器
    首先标记出所需要回收的对象,在标记完成之后统一回收所有被标记的对象。
    不足:效率问题,标记和清除两个过程效率都不高;空间问题,标记清楚之后会产生大量不连续的内存碎片。
    复制算法(解决效率问题)
    使用:一般用于新生代
    使用这种算法的垃圾收集器:serial new,parallel new和parallel scanvage垃圾收集器
    内存浪费的解决方案:新生代的内存配比是Eden:From Survivor:To Survivor=8:1:1
    该算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块;当这一块内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这种算法的代价就是将内存缩小为原来的一半。
    标记-整理算法(解决空间不连续问题)
    使用:用于老年代(存货对象比较多的情况下)
    使用该算法的垃圾收集器:parallel Old和Serial Old收集器
    算法标记过程与标记清除算法一样,算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
    分代收集算法
    将Java堆分为新生代和老年代:新生代中,每次垃圾收集时都发现有大批对象死去,只有少量对象存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;而老年代因为对象存活率高,没有额外空间对它进行分配担保,就必须使用标记-清除或者标记-整理算法进行回收。

    垃圾收集器

  1. 并发跟并行的解释:
  2. 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态
  3. 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行在另一CPU

Serial收集器(新生代收集器)

该垃圾收集器是最基本的、最早的垃圾收集器;该垃圾收集器工作时必须暂停其他所有的工作线程,直到它收集结束,这也是的它比其他收集器单线程优秀的地方:简单而高效。

ParNew收集器(新生代收集器)
  • Parnew收集器是Serial收集器的多线程版本,它行为中包括了Serial收集器可用的所有控制参数(如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配原则、回收策略等。
  • 它(除了Serial收集器)唯一可以与CMS收集器(是HotSpot虚拟机中第一款真正意义上的并发收集器,它是老年代的收集器)一块使用;
  • ParNew收集器也是使用-XX:+UseConcMarkSweepGC选项后的默认新生代收集器,也是使用-XX:+UseParNewGC选项来强制指定它;
  • 它默认开启的收集线程数与CPU数量相同,当CPU的数量很多时(超过32个),可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数;

Parallel Scavenge收集器(新生代收集器)
  • 该垃圾收集器是一个新生代收集器,同是该收集器使用的是复制算法的收集器,又是一个并行的多线程收集器;
  1. Parallel Scavenge收集器与CMS等收集器之间的区别:
  2. CMS等收集器是做尽可能的缩短垃圾收集时用户线程停顿时间;
  3. Parallel Scavenge收集器是达到一个可控制的吞吐量,吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间 + 垃圾收集时间);

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量分别为:

  • 控制最大垃圾收集停顿时间 -XX:MaxGCPauseMillis参数,参数允许的值是一个大于0的毫秒数,该参数数值不是设置的越小越好,应保证参数的值超过收集器保证内存回收花费的时间,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换去的;
  • 直接设置吞吐量大小的-XX:GCTimeRatio参数,参数的值应当是一个大于0且小于100的整数,也就是垃圾回收时间占总时间的比率,相当于吞吐量的倒数。
  • Parallel Scavenge还有一个参数-XX:+UseAdaptiveSizePolicy,这是一个开关参数,当这个参数打开后就不需要手工设置新生代的大小(-Xmn)、Eden和Survivor区的比例(-XX:SurvivorRatio)、晋升年老代对象年龄(-XX:PretenureSizeThreshold)等参数,会动态的调整这些参数以提供最合适的停顿时间或者最大的吞吐量,该调节方式被称为GC自适应的调节策略。
  1. 自适应调节是ParNew收集器与Parallel Scavenge收集器最大的区别

Serial Old收集器
  • Serial Old收集器是Serial收集器的老年代版本,同样是个单线程收集器,使用标记-整理算法;
  • 此收集器是给client模式下的虚拟机使用;
  • 在server模式下,有两大用途:一、jdk1.5之前与Parallel Scavenge收集器搭配使用;二、作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用;

Parallel Old收集器(jdk1.6中开始使用)
  • Parallel Old收集器是Parallel Scavenge收集器的老年代版,使用多线程和标记-整理算法;
  • 吞吐量优先收集是指 Parallel Old收集器和Parallel Scavenge收集器一块使用;

CMS收集器
  • CMS收集器是一种以获取最短回收停顿时间为目标的的收集器,注重应用响应速度优先选择;
  • CMS收集器是基于”标记-清除”算法实现的;
  • 运作过程分为4个步骤:

    • 初始标记:只标记和GC Roots直接关联的对象,速度快,需要暂停所有的工作线程
    • 并发标记:和用户一起工作的线程,执行GC Roots跟踪标记过程,不需要暂停工作线程
    • 重新标记:在并发标记过程中,用户线程继续运行,导致垃圾回收过程中部分对象的状态发生变化,为了确保这部分对象的准确性,需要对其重新标记并暂停工作线程;
    • 并发清除:和用户一起工作的线程,执行清除GC Roots不可达对象的任务,不需要暂停工作线程
      G1垃圾收集器
  • 为避免全区域垃圾收集引起的系统停顿,将堆内存分为大小固定的几个独立区域,独立使用这些区域的内存资源并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表;

  • 相对于CMS垃圾收集器,G1垃圾收集器的改进:
    • 基于标记整理算法,不产生碎片
    • 可以精确的控制停顿时间,在不牺牲吞吐量的前提下实现最短停顿垃圾回收

作者 @zzxhub