Java虚拟机

Posted by Procon on June 8, 2019

JVM

Java内存区域

1. 运行时数据区域

image-20200828100054215

  • 程序计数器:程序控制流的指示器,分支、循环、跳转、异常处理、线程中断恢复等都需要依赖程序计数器。在多线程中每个线程需要一个单独的程序计数器。
  • Java虚拟机栈:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

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

1. 分代收集

部分收集(Partial GC):不是完整的收集整个堆内存,分为:

  • 新生代收集(Minor GC / Young GC):目标只是新生代的收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

2.垃圾收集算法

2.1 标记-清除算法

  • 概念:标记需要清除的对象,统一回收被标记的对象(亦可以反过来);

  • 缺点:

    • 容易产生空间碎片:回收的内存空间是不连续的,当要分配大对象的时候出现内存空间不足又促发垃圾回收。
    • 效率不稳定:执行效率都随对象数量增长而降低。

2.2 复制-清除算法

  • 概念:把内存区域分成大小相等的两块,一块当作保留区域。当一块区域用完后,就把存活的对象移到另一块区域,同时清空该区域。
  • 缺陷:内存浪费严重,可用内存减少了一半。

2.3 标记-整理算法

概念:还是使用了标记-清除算法,标记后不直接清除,先把可存活对象移动到内存一侧在清理另一侧内存,可以解决空间碎片问题。

缺点:会造成系统延迟。

3. 常见垃圾收集器

image-20200828135810387

说明:如果两个收集器之间存在连线,就说明它们可以搭配使用[插图],图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器

4.内存分配策略

  • 对象优先在新生代Eden区分配,如果内存不足虚拟机会发起一次Minor GC收集(-XX:+PrintGCDetails:打印内存回收日志的详细信息)。
  • 大对象直接进入老年代。
  • 长期存活对象进入老年代

4.1 对态对象年龄判断

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

4.2 空间分配担保

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

5.调优案例分析

单体应用在较大内存的硬件上主要的部署方式有两种:
  • 通过一个单独的Java虚拟机实例来管理大量的Java堆内存。
  • 同时使用若干个Java虚拟机,建立逻辑集群来利用硬件资源。

## 类文件结构

class文件格式

image-20200828142838536

magic: 值为0xCAFEBABE,唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件

Minor Version: 次版本号

Major Version:主版本号(JDK 1.1之后的每个JDK大版本发布主版本号向上加1(JDK1.0~1.1使用了45.0~45.3的版本号)。

constant_pool: 常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。

constant_pool_count: 由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值

access_flags: 这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。

this_class: 类索引—–用于确定这个类的全限定名。

super_calass: 父索引—-除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。

implement _class: 接口索引—-接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements关键字(如果这个Class文件表示的是一个接口,则应当是extends关键字)后的接口顺序从左到右排列在接口索引集合中。

filed_info: 字段表—用于描述接口或者类中声明的变量。

类的加载过程

1. 加载过程

##### 1.1 需要立即初始化的场景

  • new、getstatic、putstatic、invokestatic遇到这四个字节码指令(对应的java场景:new关键字、读取或设置一个类型的静态字段、调用一个类型的静态方法)

  • 子类初始化时父类没有初始化,要初始化父类
  • 使用反射方法对类型初始化
  • 虚拟机启动时初始化主类
  • default关键字修饰的接口,实现类发生变化时
  • 如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化
1.2 类的加载
  • 根据类的全限定名获取二进制流文件
  • 将字节流代表的静态存储结构转换为方法区的运行时的数据结构
  • 在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据访问入口
1.3 验证

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

1.4. 准备

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

1.5. 解析

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

2. 类加载器

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

2.1 双亲委派机制

两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器使用C++语言实现(hotspot JDK9之前),是虚拟机自身的一部分。负责加载存放在JAVA_HOME/lib目录(或者被-Xbootclasspath参数所指定的路径中存放的)并且符合命名规范的类库如rt.jar、tools.jar。无法被Java程序直接引用
  • 其他类加载器:这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoad。
    1. 扩展类加载器(Extension Class Loader):

      加载存放再JAVA_HOME/ext目录的类库(或者被java.ext.dirs系统变量所指定的路径中所有的类库)。

    2. 应用类加载器(Application Class Loader):

      这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

类加载双亲委派模型如下图所示:

image-20200830102726761

双亲委派模型的工作过程是:

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

image-20200830153501543

Java内存模型与线程

1.Java内存模型

内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图:

image-20200830165426230

1.1 内存间的交互

八种操作:

 锁定(lock):

作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

 解锁(unlock):

作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

 读取(read):

作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

 载入(load):

作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中

 使用(use):

作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

 赋值(assign):

·assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作

 存储(store):

作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

 写入(write):

作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

volatile关键字:
  • 可见性:加了volatile的关键字的变量对所有线程是可见的。
  • 禁止指令重排序优化:指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。但并不是说指令任意重排,处理器必须能正确处理指令依赖情况保障程序能得出正确的执行结果。

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

  • 原子性:由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的。
  • 可见性:指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。
  • 有序性:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。

1.3 先行发生原则

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

  • 程序次序规则(Program Order Rule):

    在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。

  • 管程锁定规则(Monitor Lock Rule):

    一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。

  • volatile变量规则(Volatile Variable Rule):

    对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。

  • 线程启动规则(Thread Start Rule):

    Thread对象的start()方法先行发生于此线程的每一个动作。

  • 线程终止规则(Thread Termination Rule):

    线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。

  • 线程中断规则(Thread Interruption Rule):

    对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。

  • 对象终结规则(Finalizer Rule):

    一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

  • 传递性(Transitivity):

    如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

Java线程

image-20200830225905005

1. 线程状态转换

  • 新建(New):

    创建后尚未启动的线程处于这种状态。

  • 运行(Runnable):

    包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。

  • 无限期等待(Waiting):

    处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:

​ ■没有设置Timeout参数的Object::wait()方法;

​ ■没有设置Timeout参数的Thread::join()方法;

​ ■LockSupport::park()方法。

  • 限期等待(Timed Waiting):

    处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:

    ■Thread::sleep()方法;

    ■设置了Timeout参数的Object::wait()方法;

    ■设置了Timeout参数的Thread::join()方法;

    ■LockSupport::parkNanos()方法;

    ■LockSupport::parkUntil()方法。

  • 阻塞(Blocked):

    线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。

  • 结束(Terminated):

    已终止线程的线程状态,线程已经结束执行

image-20200830231235172

2. 线程安全

概念:

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

2.1 Java操作共享数据分类

  • 不可变:只要一个不可变的对象被正确地构建出来(即没有发生this引用逃逸的情况),那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性是最直接、最纯粹的。
  • 绝对线程安全:要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”可能需要付出非常高昂的,甚至不切实际的代价。(Vector)
  • 线程兼容
  • 线程对立

2.2 线程安全的实现

  • 互斥同步(Mutual Exclusion & Synchronization):

    同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。因此在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的

  • ·等待可中断:

    是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。

  • 公平锁:

    是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量

  • 锁绑定多个条件:

    是指一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。

性能已经不再是选择synchronized或者ReentrantLock的决定因素,在synchronized与ReentrantLock都可满足需要时优先使用synchronized。

  • 非阻塞同步(Non-Blocking Synchronization):

    互斥同步属于一种悲观的并发策略,其总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都会进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁),这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。随着硬件指令集的发展,我们已经有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free)编程。

  • 比较并交换(Compare-and-Swap,`CAS`):

    CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。

2.3 自旋锁与自适应自旋

自旋锁:

为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁,自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改。在JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。