面试复习-JVM

915 阅读43分钟

深入理解java虚拟机第三版笔记,如有错误或者需要补充的评论区讨论

内存管理

运行时数据区

  • Java虚拟机在执行Java程序的过程中会把它所管理的内存划分\color{red}{内存划分}为若干个不同的数据区域。
  • 这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在。
  • 有些区域则是依赖用户线程的启动和结束而建立和销毁。

程序计数器

线程私有\color{red}{线程私有}

  • 每个线程有个自己的程序计数器,是一块很小的内存空间,可以看成当前线程所执行的字节码的行数指示器
  • 在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 例如线程被唤醒的时候需要找到上次执行字节码的位置,继续执行。
  • 如果执行的是java方法,那么程序计数器里对应的是字节码的地址。
  • 如果执行的是native的方法,那么计数器对应的是空,此区域是java虚拟机规范中唯一一个没有规定任何outofmemoryerr的区域

Java虚拟机栈

线程私有\color{red}{线程私有}

  • 虚拟机栈描述的是Java方法执行的线程内存模型
  • 每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。
  • 每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • 栈溢出:如果线程请求的栈深度大于虚 拟机所允许的深度,将抛出StackOverflowError异常;
  • 内存溢出:如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
  • 内存泄露:程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费。

本地方法栈

本地方法栈(Native M ethod Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机 栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

Java堆Heap

线程共享\color{red}{线程共享}

  • Java堆(Java Heap)是虚拟机所管理的内存中最大的一块,Java堆是被所有线程共享的一块内存区域。
  • 在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。
  • 随着Java语言发展,即时编译技术的进步,尤其逃逸分析技术的日渐强大,栈上分配标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

逃逸分析

  • 逃逸分析的基本行为就是分析对象动态作用域:
  • 当一个对象在方法中被定义后,它可能逃出这个方法体的范围,可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
public static StringBuffer craeteStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}

public static String createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}
  • 第一段代码中的sb就逃逸了,因为变量sb是定义在craeteStringBuffer方法内部,但是却被当做返回值返回了,这就代表其sb的作用域跑出了这个方法之外
  • 第二段代码中的sb就没有逃逸,因为sb依然只存在craeteStringBuffer方法内,返回值被转成toString()一个新变量返回了。

逃逸状态

  • 全局逃逸(GlobalEscape)即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:
  • 对象是一个静态变量。
  • 对象是一个已经发生逃逸的对象。
  • 对象作为当前方法的返回值
  • 参数逃逸(ArgEscape)即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的。
  • 没有逃逸,即方法中的对象没有发生逃逸。

使用逃逸分析,编译器可以对代码做如下优化

  • 同步省略(锁消除)。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  • 例如 StringBuffer 和 Vector 都是用 synchronized 修饰线程安全的,但大部分情况下,它们都只是在当前线程中用到,这样编译器就会优化移除掉这些锁操作
  • 栈上分配:当对象没有发生逃逸时,该对象就可以分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能,但是hotspot没有实现这个优化,暂时还是标量替换。
  • 标量替换:如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。
  • 例如 User user=new User("张三","18"); 如果分析出来这个对象只用到了name 和age属性 这个对象没有用到,那么不用创建对象,直接在栈上创建name=张三 age=18 两个变量即可。

在Java代码运行时,通过JVM参数可指定是否开启逃逸分析,

  • -XX:+DoEscapeAnalysis : 表示开启逃逸分析
  • -XX:-DoEscapeAnalysis : 表示关闭逃逸分析 从jdk 1.7开始已经默认开始逃逸分析,如需关闭,需要指定-XX:-DoEscapeAnalysis

TLAB(线程本地分配缓存区)

  • JVM在内存新生代Eden Space中开辟了一小块线程私有的区域,称作TLAB(Thread-local allocation buffer)。默认设定为占用Eden Space的1%。
  • 在Java程序中很多对象都是小对象且用过即丢,它们不存在线程共享也适合被快速GC,所以对于小对象通常JVM会优先分配在TLAB上,并且TLAB上的分配由于是线程私有所以没有锁开销。
  • 因此在实践中分配多个小对象的效率通常比分配一个大对象的效率要高。Java中每个线程都会有自己的缓冲区称作TLAB(Thread-local allocation buffer),每个TLAB都只有一个线程可以操作,
  • TLAB结合bump-the-pointer技术可以实现快速的对象分配,而不需要任何的锁进行同步,也就是说,在对象分配的时候不用锁住整个堆,而只需要在自己的缓冲区分配即可。

方法区

线程共享\color{red}{线程共享}

  • 方法区只是放类对象的描述信息和对象指针,不存类对象实例,类对象实例在堆上
  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
  • 虽然《Java虚拟机规范》中把 方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区 分开来。
  • 到了 JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta- space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中
  • 线程共享,存储已被虚拟机加载的类信息,常量,静态变量,代码缓存等数据。jdk8之后 取消了永久代,将其中的字符串常量池,静态变量等移除,放入到元空间(本地内存)中。
  • 一般来说这个区域的gc回收主要是针对字符串常量池的回收和类型的卸载。运行时常量池,是方法区的一部分,存储常量表

运行时常量池

  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字 段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),
  • 用于存放编译期生 成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

直接内存

  • 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中 定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
  • 直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到 本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制

对象的创建

  • 当Java虚拟机遇到一条字节码new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。
  • 假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
  • 但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称 为“空闲列表”(Free List)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Comp act)的能力决定。
  • 因此,当使用Serial、ParNew等带压缩整理过程的收集器时,分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存
  • 除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
  • 解决这个问题 有两种可选方案:
  • 一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;
  • 另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来 设定

对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头

HotSpot虚拟机对象的对象头部分包括两类信息

1. Mark Word

  • 存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
  • lock: 2位 锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同
  • biased_lock:1位 对象是否启用偏向锁标记。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁
  • age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代
  • 默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
  • identity_hashcode:25位 对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中。
  • thread:持有偏向锁的线程ID
  • epoch:偏向时间戳。
  • ptr_to_lock_record:指向栈中锁记录的指针。
  • ptr_to_heavyweight_monitor:指向管程Monitor的指针

2. KlassPoint

即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位

3. 数组长度

如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小

4. 指针压缩

最初的时候,JVM是32位的,但是随着64位系统的兴起,JVM也迎来了从32位到64位的转换,32位的JVM对比64位的内存容量比较有限,但是我们使用64位虚拟机的同时,也带来了一个问题,64位下的JVM中的对象会比32位中的对象多占用1.5倍的内存空间,这是我们不想看到的(又要马儿跑,又要马儿不吃草可还行?),但是机智的程序员不会屈服,所以在JDK1.6的版本后,我们在64位中的JVM中可以开启指针压缩(UseCompressedOops)来压缩我们对象指针的大小来帮助我们节约内存空间,拿JDK8来说,这个指令是默认开启的。

指针压缩的大概原理:

  • 通过对齐,还有偏移量将64位指针压缩成32位
  • 零基压缩是针对压缩解压动作的进一步优化。 它通过改变正常指针的随机地址分配特性,强制堆地址从零开始分配

实例数据

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字 段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来

对齐填充

HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是 任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者 2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

对象的访问

1. 句柄访问

如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信

2. 直接指针

如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关 信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销 使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,HotSpot虚拟机,它主要使用第二种方式进行对象访问

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

判断对象是否已死

引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可 能再被使用的

  • 优点:判断原理简单,效率高。
  • 缺点:如果两个对象互相引用,但是没有任何其他的地方来引用他们,也无法判断为垃圾。 可达性分析算法 通过一系列的GC Roots的根对象作为起始点,利用引用关系向下搜索,搜索走过的路径称为引用链,如果某个对象到达GCRoots间没有任何引用相连,证明这个对象是不可达的,是不再被使用的 哪些对象可以作为GC Roots
  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JM XBean、JVM TI中注册的回调、本地代码缓存等。
  • 还有一些临时性的对象加入。 GC Roots枚举
  • 遍历方法区和栈区查找(保守式 GC)
  • 通过 OopMap 数据结构来记录 GC Roots 的位置(准确式 GC) 遍历方法区和栈区查找(保守式 GC) 垃圾收集时,收集线程会对栈上的内存进行扫描,看看哪些位置存储了 Reference 类型。如果发现某个位置确实存的是 Reference 类型,就意味着它所引用的对象这一次不能被回收。但问题是,栈上的本地变量表里面只有一部分数据是Reference类型的(它们是我们所需要的),那些非Reference类型的数据对我们而言毫无用处,但我们不得不对整个栈全部扫描一遍,是对时间和资源浪费。 OopMap 用于枚举 GC Roots
  • HotSpot ,它使用一种叫做OopMap的数据结构来记录这类信息。
  • 我们知道,一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点。 gc 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OopMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OopMap,通过栈中记录的被引用对象的内存地址,即可找到这些对象(GC Roots)SafePoint安全点(针对正在执行的线程)
  • 为什么需要设置SafePoint安全点,因为虽然OopMap可以快速准确地完成GC Roots枚举,但是如果为每一条指令都生成对应的OopMap,那么会导致大量的额外空间,对垃圾收集带来更大的空间成本。
  • HotSpot也的确没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint),也就是说在安全点维护一个OopMap,在安全点才可以进行垃圾收集。
  • 所以安全点不能太少以至于让垃圾收集器等待太长时间,也不能太多,以至于过增大内存负荷。
  • 如何才能在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来。
  • 抢先式中断 在 GC 发生时,首先中断所有线程,如果发现线程未执行到 Safe Point,就恢复线程让其运行到 Safe Point 上
  • 主动式中断 在 GC 发生时,不直接操作线程中断,而是简单地设置一个标志,让各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起。 Hotspot采取的就是主动式中断。轮询标志的地方和安全点是重合的

安全区域(针对处于sleep或者中断状态的线程)

  • 如果一个线程处于Sleep或中断状态,它就不能响应JVM的中断请求,再运行到Safe Point上。因此 JVM 引入了 Safe Region。
  • Safe Region 是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。
  • 线程在进入 Safe Region 的时候先标记自己已进入了 Safe Region,等到被唤醒时准备离开 Safe Region 时,先检查能否离开,
  • 如果 GC 完成了,那么线程可以离开,否则它必须等待直到收到安全离开的信号为止

记忆集(Remembered Set)与卡表

  • RememberedSet 用于处理这类问题:比如说,新生代 gc (它发生得非常频繁)。一般来说, gc 过程是这样的:首先枚举根节点。根节点有可能在新生代中,也有可能在老年代中。
  • 这里由于我们只想收集新生代(换句话说,不想收集老年代),所以没有必要对位于老年代的 GC Roots 做全面的可达性分析。
  • 但问题是,确实可能存在位于老年代的某个 GC Root,它引用了新生代的某个对象,这个对象你是不能清除的。那怎么办呢?
  • 仍然是拿空间换时间的办法。事实上,对于位于不同年代对象之间的引用关系,虚拟机会在程序运行过程中给记录下来。
  • 对应上面所举的例子,“老年代对象引用新生代对象”这种关系,会在引用关系发生时,在新生代边上专门开辟一块空间记录下来,这就是 RememberedSet 。
  • 所以“新生代的 GC Roots ” + “ RememberedSet 存储的内容”,才是新生代收集时真正的 GC Roots 。然后就可以以此为据,在新生代上做可达性分析,进行垃圾回收。
  • 我们知道, G1 收集器使用的是化整为零的思想,把一块大的内存划分成很多个域( Region )。
  • 但问题是,难免有一个 Region 中的对象引用另一个 Region 中对象的情况。
  • 为了达到可以以 Region 为单位进行垃圾回收的目的, G1 收集器也使用了 RememberedSet 这种技术,在各个 Region 上记录自家的对象被外面对象引用的情况。
  • 卡表(CARD_TABLE)是Remembered Set的一种实现方式。

引用

引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱

  • 强引用:Object obj=new Object(); 无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回 收掉被引用的对象
  • 软引用:用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
  • 弱引用:也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只 能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只 被弱引用关联的对象
  • 虚引用:也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚 引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知

如何判断对象是否真正死亡

  • 可达性分析算法判定为不可达的对象,并不会马上被回收,一个对象被回收,至少要经历2次标记过程:
  • 可达性算法判定没有引用链,为不可达对象,第一次标记。
  • 判断对象是否实现了finalize方法,如果没有实现,就一次标记就够了,等着回收。如果实现了finalize方法,并且 该方法已经被虚拟机调用过一次,那么也等着回收。
  • 如果没有被虚拟机调用过,那么会被放在一个F-Queue队列中,稍后虚拟机会建立一个优先级低的线程去执行这个队列里的对象的finalize方法,之后收集器会对F-queue中的对象进行第二次标记,
  • 如果标记之前,对象在finalize方法中将自己重新与引用链上的对象建立引用,那么就会逃过这次标记,移出即将被回收的集合。但是对象的finalize只能执行一次,也就是说 只能自救一次

垃圾收集算法

分代收集

1. 标记清除

  • 首先标记出所有需要回 收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回 收所有未被标记的对象。
  • 缺点:
  • 执行效率不稳定,如果Java堆中包含大量对 象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过 程的执行效率都随对象数量增长而降低
  • 内存空间的碎片化问题,标记、清除之后会产生大 量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找 到足够的连续内存而不得不提前触发另一次垃圾收集动作

2. 标记复制

  • 内存容量一分为二,每次只用其中一块,内存用完,将活着的对象复制到另一块空间,将前一块进行回收,缺点是浪费了一半的内存使用。
  • 优化: 将内存空间范围 eden,survivor,survivor 8:1:1,刚开始是在eden分配对象,当eden内存满的时候,将其中存活的对象移动到survivor中 之后将eden回收,再来新对象在eden分配,再满的时候将eden中存活的对象和survivor中存活的对象一起复制到另一个survivor中,之后将eden和刚才的survivor回收,有一种极端的情况,eden和survivor中存活的对象如果量太大,导致剩下的survivor放不下,那么需要虚拟机的 ”分配担保“机制,这些对象会直接进到老年代。虚拟机可以设置大对象的阈值,超过这个阈值,即使有内存空间也会直接移动到老年代因为防止大对象再from to 两个空间来回复制消耗性能,也可以设置年龄,存活的对象每移动一次年龄+1,到达15岁(默认)会移动到老年代。
  • 还有一种,如果survivor中相同年龄的的对象的总和大于survivor的空间的一半,那么年龄大于等于该年龄的都直接进入老年代。

3. 标记整理

主要针对老年代,将存活的对象向内存的一边移动,之后清理掉边界之外未存活的对象,与标记清除的区别,标记清除是非移动回收算法,而标记整理是移动式的。标记整理会导致暂停用户应用程序,STOP THE WORD!

三色标记

在CMS和G1并发标记的过程中会采用三色标记算法,它是用来解决GC运行时程序长时间挂起的问题,最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个GC

    1. 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
    1. 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
    1. 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。 三色标记的缺陷
1. 浮动垃圾:并发标记的过程中,若一个已经被标记成黑色或者灰色的对象,突然变成了垃圾,由于不会再对黑色标记过的对象重新扫描,所以不会被发现,那么这个对象不是白色的但是不会被清除,
   重新标记也不能从 GC Root 中去找到,所以成为了浮动垃圾, 浮动垃圾对系统的影响不大,留给下一次GC进行处理即可
2. 对象漏标问题(需要的对象被回收):并发标记的过程中,一个业务线程将一个未被扫描过的白色对象断开引用成为垃圾(删除引用),同时黑色对象引用了该对象(增加引用)(这两部可以不分先后顺序);
   因为黑色对象的含义为其属性都已经被标记过了,重新标记也不会从黑色对象中去找,导致该对象被程序所需要,却又要被GC回收,此问题会导致系统出现问题,而 CMS 与 G1 ,
   两种回收器在使用三色标记法时,都采取了一些措施来应对这些问题, CMS对增加引用环节进行处理(Increment Update),G1则对删除引用环节进行处理(SATB)

解决办法

1. cms 使用增量更新(Incremental Update)
在一个未被标记的对象(白色对象)被重新引用后, 引用它的对象若为黑色则要变成灰色,在下次二次标记时让GC线程继续标记它的属性对象 。
但是就算时这样,其仍然是存在漏标的问题
2. g1 使用原始快照(Snapshot At The Beginning, SATB)
在开始标记的时候生成一个快照图标记存活对象,在一个引用断开后,要将此引用推到GC的堆栈里,保证白色对象(垃圾)还能被GC线程扫描到(写屏障里把所有旧的引用所指向的对象都变成非白的)。
配合 Rset ,去扫描哪些Region引用到当前的白色对象,若没有引用到当前对象,则回收。

SATB效率高于增量更新的原因

SATB在重新标记环节只需要去重新扫描那些被推到堆栈中的引用,并配合 Rset 来判断当前对象是否被引用来进行回收;
并且在最后 G1 并不会选择回收所有垃圾对象,而是根据 Region 的垃圾多少来判断与预估回收价值(指回收的垃圾与回收的 STW 时间的一个预估值),将一个或者多个 Region 放到 CSet 中,
最后将这些 Region 中的存活对象压缩并复制到新的 Region 中,清空原来的 Region 

垃圾收集器

Serial

新生代 单线程 标记复制算法 Stop The World

单线程、进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。“Stop The World”

Serial Old

老年代 单线程 标记-整理算法 Stop The World

Serial的老年代版本
SerialSerial Old这两种垃圾处理器已经很少见了,它们两的原理相同,只是用于的区域不同,Serial用于新生代,Serial Old用于老年代
Serial垃圾回收器在每次进行GC操作的时候,会停止所有的业务(这个操作简称STW),然后由一个GC单线程来做垃圾回收。如果缩短STW的时间,是JVM调优的一个很严重的话题

ParNew

新生代-标记复制算法 老年代-标记整理算法 多线程 Stop The World

Serial收集器的多线程并行版本

Parallel Scavenge

新生代-标记复制算法 多线程 吞吐量优先收集器 Stop The World

基于标记-复制算法实现的收集器,也是 能够并行收集的多线程收集器

Parallel Old

老年代-标记整理算法 多线程 Stop The World

Parallel Old是Parallel Scavenge收集器的老年代版本

CMS

标记清除算法

以获取最短回收停顿时间为目标的收集器,基于标记-清除算法实现,并发收集,低停顿
1. 初始标记:标记GCRoots能直接关联到的对象。 Stop The World
2. 并发标记:从GCRoots关联的对象开始遍历整个对象图的过程,时间较长,但是可以与垃圾收集线程并行。
3. 重新标记:修复并发标记期间程序产生变动的对象的标记记录。 Stop The World
4. 并发清除:清理删除掉标记阶段判定的已死亡对象

缺点:
1. 无法清理”浮动垃圾“:
   也就是在标记和清理阶段,用户线程还在继续运行,还会产生新的垃圾对象,但是因为是出现在标记之后的,所以无法清理,只能等到下次标记,而且用户线程还在运行,所以还需要预留内存空间来分配对象,
   不能等到老年代快填满的时候再清理,当老年代使用68%的时候会被激活,数值太小偏保守,数值太大会导致预留的空间不够分配大对象,从而导致并发失败,这个时候虚拟机会冻结用户线程,
   临时启用serialold收集器来进行老年代的垃圾收集,会导致停顿时间过长。
2. 使用标记-清理:
   会产生大量的内存碎片,如果不够分配大对象,会导致fullgc。

G1

1. 在G1收集器出现之前的所有 其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老 年代(M ajor GC),再要么就是整个Java堆(Full GC)
2. G1不再按照固定大小和和固定数量来进行分代,而是把连续的java堆划分为多个大小相等的独立区域region,每个region有自己的标记(eden,suvivor,old)
   每个region区域大小为1-32m 可以设置 2的幂,size =(堆最小值+堆最大值)/ TARGET_REGION_NUMBER(2048) ,然后size取最靠近2的幂次数值, 并将size控制在[1M,32M]之间
   G1全局标记,确定堆里的对象存活时间,G1可以判断出哪些regions空间最多,可以最大的回收价值,可以根据用户设置的暂停时间目标来计算需要释放多少个regions的区域
   当Eden空间被占满之后,就会触发YGC。在G1中YGC依然采用复制存活对象到survivor空间的方式,当对象的存活年龄满足晋升条件时,把对象提升到old generation regions(老年代)。
3. G1控制YGC开销的手段是动态改变young region的个数,YGC的过程中依然会STW(stop the world 应用停顿),并采用多线程并发复制对象,减少GC停顿时间
   因GCRoots里包含了老年代的对象,但是YGC的时候如果扫描老年代会很耗时耗性能,G1用了rememberset数据结构简称rset记录了其他region的对象和本region对象的引用关系,
   代表 谁引用了我的对象,本质是个hashtable,key是别的region地址,value是一个集合,每个region都有自己的rset,所以不需要扫描老年代,只需要扫描rset就知道老年代中引用了多少新生代的对象
4. Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象,而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中

ZGC

ZGC希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟

内存分配与回收策略

对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起
一次Minor GC

大对象直接进入老年代

大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组,大对象直接进入老年代。

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

对象通常在Eden区里诞生,如果经过第一次 Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象 年龄设为1岁。
对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程 度(默认为15,记录在对象头中),就会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置

动态对象年龄判定

如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄

空间分配担保

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。
如果不成立,则虚拟机会先查看- XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);
如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,
如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;
如果小于,或者-XX: HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC

类加载

类加载机制

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

类加载时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备、解析三个部分统称为连接

加载

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

验证

文件格式验证、元数据验证、字节码验证和符号引用验证

准备

1. 准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段
2. 这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
3. static修饰的类变量会赋值类型初始化的零值,static int a=123,在准备阶段会赋值0static final int a =123 javac会为a生成constantValue属性,则会直接赋值123

解析

1. 类或接口的解析
2. 字段解析
3. 方法解析
4. 接口方法解析

初始化

1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段
	1.1 使用new关键字实例化对象的时候。·
	1.2 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
	1.3 调用一个类型的静态方法的时候

2. 通过子类引用父类的静态字段,不会导致子类初始化。
3. 通过数组定义来引用类,不会触发此类的初始化。
4. 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
5. 通过数组来定义类 例如SuperClass[] a = new SuperClass[10];不会初始化SuperClass类。
6.使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
7. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。接口除外,接口初始化的时候不需要其父类全部初始化,只有在真正使用到父接口的时候才会初始化。
8. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
9. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
10. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化
11. 静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问

12. 父类中定义的静态语句块要优先于子类的变量赋值操作

类加载器

在java角度,只存在两种类加载器,一种是启动类加载器,用c++实现,是虚拟机的一部分。另一种是所有类的加载器,是java实现的,继承java.lang.ClassLoader。
1. 引导类加载器(Bootstrap Class Loader): 该类加载器使JVM使用C/C++底层代码实现的加载器,用以加载JVM运行时所需要的系统类,这些系统类在{JRE_HOME}/lib目录下。
   由于类加载器是使用平台相关的底层C/C++语言实现的, 所以该加载器不能被Java代码访问到。但是我们可以查询某个类是否被引导类加载器加载过。
   我们经常使用的系统类如:java.lang.String,java.lang.Object,java.lang*..这些都被放在 {JRE_HOME}/lib/rt.jar包内,当JVM系统启动,引导类加载器会将其加载到JVM内存的方法区中。
2. 拓展类加载器(Extension Class Loader): 该加载器是用于加载 java 的拓展类 ,拓展类一般会放在 {JRE_HOME}/lib/ext/ 目录下,用来提供除了系统类之外的额外功能。
   拓展类加载器是是整个JVM加载器的Java代码可以访问到的类加载器的最顶端,即是超级父加载器,拓展类加载器是没有父类加载器的。
3. 应用类加载器(Applocatoin Class Loader): 该类加载器是用于加载用户代码,是用户代码的入口。我经常执行指令 java   xxx.x.xxx.x.x.XClass , 
   实际上,JVM就是使用的AppClassLoader加载 xxx.x.xxx.x.x.XClass 类的。应用类加载器将拓展类加载器当成自己的父类加载器,当其尝试加载类的时候,首先尝试让其父加载器-拓展类加载器加载;
   如果拓展类加载器加载成功,则直接返回加载结果Class<T> instance,加载失败,则会询问是否引导类加载器已经加载了该类;只有没有加载的时候,应用类加载器才会尝试自己加载。由于xxx.x.xxx.x.x.XClass是整个用户代码的入口,
   在Java虚拟机规范中,称其为 初始类(Initial Class).
4. 用户自定义类加载器(Customized Class Loader):用户可以自己定义类加载器来加载类。所有的类加载器都要继承java.lang.ClassLoader类

双亲委派模型

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,
因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
假如用户自定义一个java.lang.Object的类,那么通过双亲委派模型,到了最上层的加载器会显示已经加载过系统中的object类,从而不会加载用户自定义的object类,保证了java的稳定运行

打破双亲委派

Tomcat 如果使用默认的类加载机制行不行?
我们思考一下:Tomcat是个web容器, 那么它要解决什么问题:
1. 一个web容器可能需要部署两个应用程序,不同应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
2. 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机,这是扯淡的。
3. web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
4. web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情, 所以,web容器需要支持 jsp 修改后不用重启

双亲委派这里稍后补充一些细节