面试准备之虚拟机

309 阅读16分钟

虚拟机

只要生成的编译文件符合JVM对加载编译文件格式要求,任何语言都可以由JVM编译运行。

市面上有多种Java虚拟机语言,既有移植至Java虚拟机的旧语言,也有全新的语言。JRubyJython可能为最知名的移植语言之二;除此之外,也有从零编写的全新语言,如热门的ClojureApache GroovyScalaKotlin。Java虚拟机语言的一大显著特征是都互相兼容,举例来说,Scala库可与Java程序互用,反之亦然。

Java虚拟机-维基百科

HotSpot

HotSpot的正式发布名称为"Java HotSpot Performance Engine",是Java虚拟机的一个实现,包含了服务器版和桌面应用程序版,现时由Oracle维护并发布。它利用JIT及自适应优化技术(自动查找性能热点并进行动态优化,这也是HotSpot名字的由来)来提高性能。

HotSpot虚拟机的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知即时编译器以方法进行编译。

如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准即时编译和栈上替换编译行为。

HotSpot

虚拟机运行时数据区域

程序计数器

程序计数器可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。每个线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,我们称这类内存区域为“线程私有”的内存。

Java虚拟机栈

生命周期与线程相同,也是线程私有的。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧。在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

HotSpot虚拟机的栈容量是不可以动态扩展的。所以在HotSpot虚拟机是不会由于虚拟机栈无法扩展而导致OutOfMemory异常,但是如果申请栈空间失败,仍然会出现OOM异常。

本地方法栈

虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务。《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定。HotSpot虚拟机直接就把本地方法栈和虚拟机栈合二为一。

Java堆

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemory Error异常。

方法区

方法区也是被各个线程共享的内存区域,被用来存储已被虚拟机加载的类型信息、常量、静态变量等数据。

运行时常量池

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等信息外,还有常量池表,用于存放编译期生成的各种字面量和符号引用。这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池相对于Class文件常量池的另外一个重要特性是具备动态性,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。

java基础:String — 字符串常量池与intern(二)

对象的内存布局

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

JAVA 对象头分析及Synchronized锁

Java对象结构

OutOfMemoryError异常

在《Java虚拟机规范》的规定中,除了程序计数器,虚拟机内存的其他几个运行时区域都有发生OutOfMememoryError异常的可能。

Java故障诊断和性能分析工具

Java OOM错误诊断方法总结

判断对象是否已死?

引用计数算法

可达性分析算法

Java中9种常见的CMS GC问题分析与解决

Java Hotspot G1 GC的一些关键技术

JVM 内存分析工具 MAT 的深度讲解与实践——入门篇

JVM性能调优监控工具 jps jstat jinfo jmap jhat jstack

虚拟机类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,这个过程被称作虚拟机的类加载机制。在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的。这为Java应用提供了极高的扩展性和灵活性。

类加载的时机

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

双亲委派模型

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。只有当父类加载器反馈自己无法完成这个加载请求,子加载器才会尝试自己去完成加载。

硬件的效率与一致性

基于高速缓存的存储交互很好地解决了处理器与内存速度之间地矛盾,但也引入了一个新的问题:缓存一致性。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,这类协议有MSI、MESI、MOSI等。

除了增加高速缓存之外,为了使处理器的运算单元被充分利用,处理器可能会对代码进行乱序执行。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有指令重排序优化。

Java内存模型

《Java虚拟机规范》中试图定义一种“Java内存模型”来屏蔽各种硬件和操作系统的内存访问差异,以实现Java程序在各个平台都能达到一致的内存访问效果。

主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。

内存间交互数据

一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存,Java内存模型定义了8种操作来完成。以下每种操作都是原子的。

  • lock:作用于主内存的变量,把一个变量标识为一条线程独占的状态。
  • unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来。
  • read:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load:作用于工作内存的变量,它把read操作从主内存得到的变量值放入工作内存变量副本中。
  • use:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎。
  • assign:作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量。
  • store:作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存,以便随后的write操作使用。
  • write:作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

把一个变量从主内存拷贝到工作内存,按顺序执行read和load操作。

把变量从工作内存同步回主内存,按顺序执行store和write操作。

Java内存模型还规定了在执行上述8种基本操作时必须满足以下规则:

  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量。

  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值。

对于volatile型变量的特殊规则

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。当一个变量被定义为volatile之后,它将具备两项特性:第一是保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

第二是禁止指令重排序优化。

原子性、可见性与有序性

基本数据类型的访问、读写都是具有原子性的。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock来满足这种需求。

除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这条规则获得的。被final修饰的字段在构造器中一旦被初始化,那么在其他线程就能看见final字段的值。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。volatile通过禁止指令重排序,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作"这条规则获得的。

Java多线程之先行发生原则(happens-before)

Java与线程

Java线程的实现

从JDK1.3起,主流平台上的主流商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。

状态转换

Java语言定义了6种线程状态,分别是:

  • 新建(New):创建后尚未启动的线程处于这种状态。

  • 运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是有可能在执行,也有可能正在等待操作系统为它分配执行时间。

  • 无限期等待(Waiting):不会被分配处理器执行时间,只能被其他线程显式唤醒。以下方法会让线程陷入无限期等待。没有设置Timeout的wait方法、没有设置Timeout的join方法、LockSupport的park()方法

  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,在一定时间之后它们会被系统自动唤醒。让线程进行限期等待状态的方法:Thread的sleep()方法、设置了TimeOut参数的wait方法、设置了Timeout的join()方法。

  • 阻塞(Blocked):线程被阻塞了。“阻塞状态”在等待获取到一个排它锁。在程序等待进入同步区域的时候,线程将进入这种状态。

  • 结束(Terminated):已终止线程的线程状态。

线程安全与锁优化

线程安全的实现方法

  • 互斥同步(悲观锁)

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

在Java里面,最基本的互斥同步手段就是synchronized关键字这是一种块结构的同步语法。经过Javac编译之后,会在同步块的前后分别形成moniterenter和moniterexit这两个字节码指令。

除了synchronized关键字以外,自JDK 5起,Java类库新提供了java.util.concurrent包。基于Lock接口,用户能够以非块结构来实现互斥同步,从而摆脱了语言特性的束缚,改为在类库层面去实现同步。

重入锁(ReentrantLock)是Lock接口最常见的一种实现。它比synchronized增加了一些高级功能,主要有:等待可中断、可实现公平锁、锁可以绑定多个条件(是指一个ReentrantLock对象可以同时绑定多个Condition对象)。

  • 非阻塞同步(乐观锁)

通俗地说就是不管风险,先进行操作,如果没有其他线程共享数据,那操作就直接成功了;如果共享地数据的确被争用,产生了冲突,那再进行其他的补偿措施。最常见的是不断地重试,直到出现没有竞争的共享数据为止。

硬件保证某些语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,这类指令常用的有:

测试并设置(Test-and-Set)、获取并增加(Fetch-and-Increment)、交换(Swap)、比较并交换(Compare-and-Swap,简称CAS)。

CAS从语义上来说并不是真正完美的,它存在一个逻辑漏洞,ABA问题。JUC包为了解决这个问题,提供了带有标记的原子引用类AtomicStampedReferance,它可以通过控制变量值的版本来保证CAS的正确性。

  • 无同步方案

如果能让一个方法本来就不涉及共享数据,那它自然就需要任何同步措施去保证其正确性。

可重入代码(Reentrant Code):这种代码又被称为纯代码(Pure Code),是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。

判断代码是否具备可重入性:如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

如果一个变量只要被某个线程独享,可以通过ThreadLocal来实现线程本地存储的功能。

JAVA 多线程之ThreadLocal详解

Java面试必问,ThreadLocal终极篇

ThreadLocal夺命4问

面试官:小伙子,听说你看过ThreadLocal源码?(万字图文深度解析ThreadLocal

锁优化

自旋锁与自适应自旋

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

JDK1.4默认关闭,JDK1.6起默认开启。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之会带来性能的浪费。因此自旋等待的时间有一定的限度,如果自旋超过了限定的次数依然没有获得锁,就会使用传统的方式挂起线程。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测不可能存在共享数据竞争的锁进行消除。

锁粗化

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

轻量级锁

如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁。轻量级锁能提升程序同步性能的依据是”对于绝大多数的锁,在整个同步周期内都是不存在竞争的“这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销。有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

偏向锁

偏向锁在无竞争的情况下把整个同步都消除掉,连CAS操作都不会去做。偏的意思是这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,该锁一直没有被其他线程获取,则持有偏向锁的线程将永远不需要再同步。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。偏向锁可以提高带有同步但无竞争的程序性能。并非总是对程序运行有利,如果程序大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。