《深入理解Java虚拟机》读书笔记--面试全面复习

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

1.1 运行时数据区域

《深入理解Java虚拟机》读书笔记--面试全面复习_第1张图片

1.1.1 程序计数器

  • 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。它是 线程私有
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

1.1.2 Java 虚拟机栈

  • Java 虚拟机栈(Java Virtual Machine Stack)也是 线程私有 的,它的生明周期与线程相同。
  • 每个方法被执行的时候,Java 虚拟机栈都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

局部变量表存放了编译器可知的各种 Java 虚拟机基本数据类型、对象引用和 returnAddress 类型(指向了一条字节码指令的地址)

这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示。局部变量表所需的内存空间在编译期完成分配,进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

1.1.3 本地方法栈

  • 本地方法栈( Native Method Stacks )与虚拟机栈发挥的作用相似,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码服务),而本地方法栈则是为虚拟机使用到的本地( Native )方法服务。本地方法栈也是 线程私有

1.1.4 Java 堆

  • Java 堆( Java Heap )是虚拟机所管理的内存中最大的一块,Java 堆是 线程共享 的,在虚拟机启动时创建,此区域的唯一目的就是存放对象实例。
  • Java 堆是垃圾收集器管理的内存区域

无论从什么角度看,无论如何划分 Java 堆的内存区域,都不会改变 Java 堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将 Java 堆细分的目的只是为了更好的回收内存,或者更快的分配内存

1.1.5 方法区

  • 方法区( Method Area )是 线程共享 的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
  • 方法区在 JDK 1.8 之前是 “永久代” 来实现,1.8 之后是由在本地内存中实现的元空间( Meta-space )来代替

1.1.6 运行时常量池

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

1.1.7 直接内存

  • 直接内存( Direct Memory )并不是虚拟机运行时数据区的一部分,但是它也可能会导致 OutOfMemoryError 异常出现

在 JDK 1.4 中新加入了 NIO 类,引入了一种基于通道( Channel )与缓冲区( Buffer )的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据

1.2对象的内存布局

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

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

  • 第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向锁ID、偏向时间戳等,这部分数据称之为 “Mark Word” 。
  • 对象的的另一部分是类型指针,即对象指向它的类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例,并不是所有的虚拟机实现都必须在对象数据上保留类型指针,此外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据。

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

对象的第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,仅仅是起着占位符的作用

1.3 对象的访问定位

主流的访问方式主要有使用 句柄直接指针 两种:

  • 如果使用句柄访问的话,Java 堆中将可能划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
《深入理解Java虚拟机》读书笔记--面试全面复习_第2张图片
  • 如果使用直接指针访问的话,Java 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址,如果只是访问对象ben’shen的话,就不需要多一次间接访问的开销
《深入理解Java虚拟机》读书笔记--面试全面复习_第3张图片

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

使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。

对于 HotSpot 虚拟机而言,主要使用第二种方式进行对象访问。

1.4 OutOfMemoryError

面试题:说一说 Java 中的内存溢出与栈内存溢出

1.4.1 Java 堆溢出

Java 堆用于存储对象实例,我们只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常,也就是 OutOfMemoryError。

常规的处理方法是首先通过内存映像分析工具对 Dump 出来的堆转储快照进行分析。第一步首先确认内存中导致 OOM 的对象是否是必要的,也就是要先分清楚到底是出现了 内存泄漏 还是 内存溢出

如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的引用链,找到泄露对象是通过怎样的引用路径、与哪些 GC Roots 相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到 GC Roots 引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找到产生内存泄漏的代码的具体位置。

如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查 Java 虚拟机参数( -Xmx 与 -Xms )设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象声明周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

1.4.2 栈内存溢出

如果线程请求的深度大于虚拟机所允许的最大深度(栈帧太大或者虚拟机栈容量太小),将抛出 StackOverflowError 异常

出现 StackOverflowError 异常时,会有明确错误栈帧可供分析,相对而言比较容易定位到问题所在。

可能原因有:

  1. 递归调用层数过深
  2. 大量循环或者死循环
  3. 全局变量是否过多
  4. 数组、List、Map 数据是否过大

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

2.1 对象是否存活?

判断对象是否存活有两者算法:

  1. 引用计数法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。引用计数法的缺陷是当两个对象互相引用时,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。
  2. 可达性分析算法:基本思路就是通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为 “引用链” ( Reference Chain ),如果某个对象到 GC Roots 间没有任何引用链相连或者用图论的话来说就是 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的

在 Java 领域,主流的 Java 虚拟机一般都使用 可达性分析算法 来判断一个对象是否可以被回收了

Java 中可以作为 GC Roots 的对象:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
  • 在方法区中类静态属性引用的对象,比如 Java 类的引用类型静态变量
  • 在方法区中常量引用的对象,比如字符串常量池(String Table)里的引用
  • 在本地方法栈中 JNI(即 Native 方法)引用的对象
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(NullPointException、OutOfMemoryError)等,还有系统加载器
  • 所有被同步锁(synchronized 关键字)持有的对象
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等

2.2 对象引用

因为有些对象 “食之无味,弃之可惜”,所以在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为 强引用(Strongly Reference)软引用(Soft Reference)、**弱引用(Weak Reference)**和 虚引用(Weak Reference),这四种引用强度依次逐渐减弱

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

2.3 回收方法区

方法区垃圾收集的 “性价比” 通常是比较低的:在 Java 堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收 70% 至 99% 的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

回收废弃常量与回收 Java 堆中的对象非常类似,当一个常量对象不再任何地方被引用的时候,则被标记为废弃常量,这个常量就可以被回收。

而判断一个类型是否属于 “不再被使用的类” 的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是 “被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制。

2.4 垃圾收集算法

2.4.1 标记-清除算法

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记的过程就是对象是否属于垃圾的判定过程。

后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的,它的缺点主要有两个:

  1. 执行效率不稳定,当 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  2. 内存空间的碎片化问题,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作;

2.4.2 标记-复制算法

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

主要缺点:

  1. 如果内存中多数对象都是存活的,标记-复制算法将产生大量的内存间复制的开销
  2. 标记-复制算法将可用内存空间缩小为原来的一半,空间浪费的较多

更优化的半区复制分代策略–Appel 式回收

Appel 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor 。发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后清理掉 Eden 和已用过的那块 Survivor 空间。

HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8 : 1,当然这是基本普通场景下 “新生代中的对象有 98% 熬不过第一轮收集”。

当然 Appel 式回收还有一个充当罕见情况的 “逃生门” 的安全设计,当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其它内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

2.4.3 标记-整理算法

其中的标记过程仍然与 “标记-清除” 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后清理掉边界以外的内存

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:

  • 移动存活对象(尤其是在老年代,每次回收有大量存活对象)并更新所有引用是一种极为负重的操作,会造成 “STW”
  • 不考虑移动对象也就是标记-清除算法,则需要注意空间碎片的问题

和稀泥式的解决方案:让虚拟机平时多数时间采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法,以获得规整的内存空间。

2.5 HotSpot 的算法细节实现

2.5.1 根节点枚举

HotSpot 使用一组称为 OopMap 的数据结构根节点的枚举,一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找。

2.5.2 安全点

HotSpot 并没有为每条指令都生成 OopMap,因为如果每条指令都生成对应的 OopMap,将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本将非常高。所以 HotSpot 只有在特定的位置会生成 OopMap ,这些位置被称为 安全点(SafePoint)。

有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。适合的安全点例如方法调用、循环跳转、异常跳转等。

对于如何在垃圾收集发生时让所有线程都跑到最近的安全点,有两种方案:

  1. 抢占式中断:这种方案不需要线程的执行代码主动去配合,而是在垃圾收集发生时,暂停所有用户线程,如果有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会儿再中断,直到跑到安全点上。现在几乎没有虚拟机使用这种方案。
  2. 主动式中断:这种方案的思想是当垃圾收集需要中断线程时,不直接对线程操作,仅仅是简单地设置一个标志位,各个线程执行过程中会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

2.5.3 安全区域

使用安全点可能会带来的问题:当用户线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间

基于此种情况,引入安全区域(Safe Region)来解决

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已经声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举,如果完成了,那线程就可以继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

2.5.4 记忆集与卡表

记忆集是一种记录从非收集区域指向收集区域的指针集合的抽象数据结构。

在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针,所以采用粒度比较大的 卡表 来进行实现

2.5.5 写屏障

这里的写屏障与解决并发乱序执行问题中的 “内存屏障” 并不是一个概念

在 HotSpot 虚拟机中是通过写屏障( Write Barrier )技术维护卡表状态的。

写屏障可以看作在虚拟机层面对 “引用类型字段赋值” 这个动作的 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫做写前屏障,在赋值后的则叫做写后屏障。

除了写屏障的开销外,卡表在高并发场景下还面临着 “伪共享” 问题,伪共享是处理并发底层细节时一种经常需要考虑的问题,现代处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

3. 虚拟机类加载机制

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

3.1 类加载的过程

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

《深入理解Java虚拟机》读书笔记--面试全面复习_第4张图片

加载

“加载”(Loading) 阶段是整个 “类加载” (Class Loading)过程中的第一个阶段,在加载阶段,Java 虚拟机需要完成以下三件事情:

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

验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全。

验证阶段大致会完成四个方面的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证

准备

准备阶段是正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段。

要注意,一般情况下变量在准备阶段过后的初始值为该数据类型的默认值(比如 int -> 0,boolean -> false),但是如果该字段是由 final 关键字定义的常量值,那在准备阶段变量值就会被初始化为所指定的初始值。

解析

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

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
初始化

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器 () 方法的过程。

使用
卸载

3.2 类加载器

Java 虚拟机设计团队有意把类加载阶段中的 “通过一个类的全限定名来获取描述该类的二进制字节流” 这个动作放到了 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称作 “类加载器”(Class Loader)。

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。即使两个类来源于同一个 Class 文件,被同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

3.3 双亲委派模型

  • 启动类加载器(Bootstrap Class Loader):负责加载存放在 \lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,而且是 Java 虚拟机能够识别的类库加载到虚拟机的内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用 null 代替即可。
  • 扩展类加载器(Extension Class Loader):这个类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式实现的。它负责加载 \lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库。根据 “扩展类加载器” 这个名称,就可以推断出这个一种 Java 系统类库的扩展机制,JDK 的开发团队允许用户将具有通过性的类库放置在 ext 目录里以扩展 Java SE 的功能。
  • 应用程序类加载器(Application Class Loader):这个类加载器由 sun.misc.Launcher$AppClassLoader 来实现。由于应用程序类加载器是 ClassLoader 类中的 getSystem-ClassLoader() 方法的返回值,所以有些场合也称它为 “系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

《深入理解Java虚拟机》读书笔记--面试全面复习_第5张图片

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。

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

使用双亲委派模型来组织类加载之间的关系,一个显而易见的 好处 就是 Java 中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object ,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都能够保证是同一个类。

3.4 破坏双亲委派机制的案例

  1. Tomcat 可以加载自己目录下的 class 文件,并不会传递给父类的加载器。
  2. Java 的 SPI,发起者是 BootstrapClassLoader,BootstrapClassLoader 已经是最上层的了,它直接获取了 AppClassLoader 进行驱动加载,和双亲委派是相反的
  3. Java 的模块化更新,每一个程序模块都有一个自己的类加载器,由双亲委派模型推荐的树状结构进一步发展为更加复杂的网状结构

4. 语法糖

4.1 泛型

Java 中泛型的实现方式叫做 “类型擦除式泛型”,它只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type),并且在元素访问时插入了从 Object 到相应类型的强制转型代码,因此对于运行期的 Java 语言来说,ArrayList 与 ArrayList 其实是同一个类型。

4.2 其他语法糖

Java 中有不少语法糖,泛型、自动装箱、自动拆箱、遍历循环、变长参数、条件编译、内部类、枚举类、断言语句、数值字面量、对枚举和字符串的 switch 支持、try语句中定义和关闭资源、Lambda 表达式等等。

5. Java 内存模型与线程

5.1 volatile

当一个变量被定义成 volatile 之后,它将具备两项特性:

  1. 第一项是保证此变量对所有线程的可见性,这里的 “可见性” 是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。
  2. 使用 volatile 变量的第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

大多数场景下 volatile 的总开销仍然要比锁来的更低。我们在 volatile 与锁中选择的唯一判断依据仅仅是 volatile 的语义能否满足使用场景的需求。

5.2 原子性、可见性与有序性

Java 内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的。

5.2.1 原子性(Atomicity)

由 Java 内存模型来直接保证的原子性操作包括read、load、assign、use、store 和 write 这六个,所以我们大致可以认为,基本数据类型的访问、读写都是具备原子性的。

另外如果需要一个更大范围的原子性保证,虚拟机提供了 monitorenter 和 monitorexit 来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块-- synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。

5.2.2 可见性(Visibility)

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此。普通变量与 volatile 变量的区别是,volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前j立即从主内存刷新。因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

除了 volatile 之外,synchronized 和 final 这两个关键字也能实现可见性。

5.2.3 有序性(Ordering)

Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指:“线程内似表现为串行的语义”,后半句是指 “指令重排序” 现象和 “工作内存与主内存同步延迟” 现象。

Java 语言提供了volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由 “一个变量在同一时刻只允许一条线程对其进行 lock 操作” 这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

5.3 先行发生原则(Happens-Before)

先行发生原则是指在 Java 内存模型中定义的两项操作之间的偏序关系,比如说操作 A 先行发生于操作 B ,其实就是说发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到,“影响” 包括修改了内存中共享变量的值、发送了消息、调用了方法等。

以下是 Java 内存模型下一些 “天然的” 先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

  • 程序次序规则

  • 管程锁定规则

  • volatile 变量规则

  • 线程启动规则

  • 线程终止规则

  • 线程中断规则

  • 对象终结规则

  • 传递性

5.4 Java 线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式线程调度和抢占式线程调度。

  1. 协同式线程调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去,使用协同式线程调度的好处是实现简单,线程自己的事情干完后才会进行线程切换;坏处是线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不通知系统进行下线程切换,那么程序就会一直阻塞。
  2. 抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定,Java 中使用此种方式实现线程调度。

5.5 线程状态转换

Java 语言定义了 6 种线程状态,在任意一个时间点中,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间转换。

  • 新建(New):创建后尚未启动的线程处于这种状态。
  • 运行(Runnable):包括操作系统线程状态中的 Running 和 Ready ,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
  • 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒(Object::wait()、Thread::join()、LockSupport::park())。
  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒
  • 阻塞(Blocked):线程被阻塞了,“阻塞状态” 与 “等待状态” 的区别是 “阻塞状态” 在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而 “等待状态” 则是在等待一段时间、或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。
《深入理解Java虚拟机》读书笔记--面试全面复习_第6张图片

6. 线程安全与锁优化

6.1 线程安全的定义

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果那就称这个对象是线程安全的。

6.2 线程安全的实现方法

6.2.1 互斥同步

互斥同步是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是常见的互斥实现方式。

在 Java 里面,最基本的互斥同步手段就是 synchronized 关键字,这是一种块结构的同步语法。synchronized 关键字经过 Javac 编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。这两个字节码指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象。如果 Java 源码中的 synchronized 明确指定了对象参数,那就以这个对象的引用作为 reference ;**如果没有明确指定,那将根据 synchronized 修饰的方法类型(如实例方法或者类方法),来决定是取代码所在的对象实例还是取类型对应的 Class 对象来作为线程要持有的锁。**另外 synchronized 是可重入锁。

除了 synchronized 关键字以外,JDK 5 之后新增的 JUC 下的 Lock 接口是 Java 中另外一种全新的互斥同步手段。基于 Lock 接口,用户能够以非块结构来实现互斥同步,从而摆脱了语言特性的束缚,改为在类库层面去实现同步,这也为日后扩展出不同调度算法、不同特征、不同性能、不同语义的各种锁提供了广阔的空间。

重入锁(ReentrantLock)是 Lock 接口最常见的一种实现,顾名思义,它与 synchronized 一样是可重入的。不过,ReentrantLock 与 synchronized 相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁以及锁可以绑定多个条件。

  • 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。
  • 公平锁:ReentrantLock 可以通过带有布尔值的构造函数实现公平锁。
  • 锁绑定多个条件:是指一个 ReentrantLock 对象可以同时绑定多个 Condition 对象。
synchronized 与 ReentrantLock 之间选哪个?

基于以下理由,推荐在 synchronized 与 ReentrantLock 都可满足需要时优先使用 synchronized:

  • synchronized 是在 Java 语法层面的同步,足够清晰,也足够简单。
  • Lock 是由程序员自己来保证在 finally 块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不会释放锁;而使用 synchronized 的话则可以由 Java 虚拟机来确保即使出现异常,锁也能被自动释放
  • 从长远来看,Java 虚拟机更容易针对 synchronized 来进行优化,因为 Java 虚拟机可以在线程和对象的元数据中记录 synchronized 中锁的相关信息,而使用 J.U.C 中的 Lock 的话,Java 虚拟机是很难得知具体哪些锁对象是由特定线程锁持有的。
6.2.2 非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步,它属于一种悲观的并发策略,无论共享的数据是否真的会出现竞争,它都会进行加锁,这将会导致用户态到核心态的转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。

随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进性操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施就是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步,使用这种措施的代码也常称为无锁编程

6.3 锁优化

6.3.1 自旋锁与自适应锁

前面说的互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给 Java 虚拟机的并发性能带来了很大的压力。

如果物理机器上有一个以上的处理器或者处理器核心,能让两个或以上的线程同时执行,我们就可以让后面请求锁的那个线程 “稍等一会儿” ,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。

JDK 6 中对自旋锁进行了优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定的。

6.3.2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是被检测到不可能存在共享数据的锁进行消除,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。

6.3.3 锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能消耗。

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

6.3.4 轻量级锁

在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为 “01” 状态),虚拟机首先将当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝。

然后,虚拟机将使用 CAS 操作尝试把对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位将转变为 “00”,表示此对象处于轻量级锁定状态。

如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为 “10”,此时 Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

轻量级锁能提升程序同步性能的依据是 “对于绝大部分的锁,在整个同步周期内都是不存在竞争的” 这一经验法则。如果没有竞争,轻量级锁便通过 CAS 操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了 CAS 操作的开销。因此有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

6.3.5 偏向锁

这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为 “01” 、把偏向模式设置为 “1”,表示进入偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 之中。如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁以及对 Mark Word 的更新操作等)。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为 “0”),撤销后标志位恢复到未锁定(标志位为 “01”)或轻量级锁定(标志位为 “00”)的状态,后续的同步操作如同上面的轻量级锁那样去执行。

开销外,还额外发生了 CAS 操作的开销。因此有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

6.3.5 偏向锁

这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为 “01” 、把偏向模式设置为 “1”,表示进入偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 之中。如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁以及对 Mark Word 的更新操作等)。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为 “0”),撤销后标志位恢复到未锁定(标志位为 “01”)或轻量级锁定(标志位为 “00”)的状态,后续的同步操作如同上面的轻量级锁那样去执行。

你可能感兴趣的:(面试准备,java,面试,后端)