只想当扫地机器人的老王

  • 首页

  • 关于

  • 标签

  • 分类

  • 归档

  • 日程表

ARTS打卡第五周

发表于 2019-07-21 | 分类于 ARTS打卡

ARTS打卡第五周

A

1109. 航班预订统计

提交记录

R

最近没有看论文,被拒稿了,心情还是有点焦虑的,论文还是要好好看的,准备接下来好好看下师妹整理的过滤泡的文件

T

关于hexo的文字统计,参考了老马的博客在做下

Hexo字数统计、阅读时长添加

S

咱们从头到尾说一次 Java 垃圾回收

ARTS打卡第六周

发表于 2019-07-21 | 分类于 ARTS打卡

ARTS打卡第六周

A

22. 括号生成

提交记录

R

略

T

idea对JVM调参说明

文档内容

S

动画:什么是BF算法?

1109. 航班预订统计

发表于 2019-07-15 | 更新于 2019-07-14 | 分类于 编程练习 , LeetCode练习

1109. 航班预订统计

这里有 n 个航班,它们分别从 1 到 n 进行编号。

我们这儿有一份航班预订表,表中第 i 条预订记录 bookings[i] = [i, j, k] 意味着我们在从 i 到 j 的每个航班上预订了 k 个座位。

请你返回一个长度为 n 的数组 answer,按航班编号顺序返回每个航班上预订的座位数。

示例:

输入:bookings = [[1,2,10],[2,3,20],[2,5,25]], n = 5

输出:[10,55,45,25,25]

提示:

1 <= bookings.length <= 20000

1 <= bookings[i][0] <= bookings[i][1] <= n <= 20000

1 <= bookings[i][2] <= 10000


思路:一般结题的思路应该是依次按照数组的规定,按照行分别去累加和来求解,但是我尝试了一下是不行的,超出时间限制(用例给的很大不能通过),参考题解才知道了怎么做

优化思路:这题可以了解一下前缀和概念,简单说一下就是比如数组[5,6,7,8]如果经过m次搜索,每次去查找l位置与r位置中的和,常规求解都是On^2,如果利用前缀和的思路可以做到线性求解,就是算出数组的sum数组[5,11,18,26],比如我想要知道1、2位置的和13(6+7)我只有拿18-5即可,详情可以百度前缀和

提交记录

11、多线程与JVM

发表于 2019-07-10 | 更新于 2019-07-09 | 分类于 看书笔记 , 深入理解Java虚拟机

一、Java内存模型与线程

1、硬件的效率与一致性

由于计算机的存储设备与处理器的运算速度有好几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。

为了解决缓存一致性问题,需要各个处理器访问缓存时都遵守一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等。而Java虚拟机也有自己的内存模型。

除了增加了高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行结果进行重组,保证结果与顺序执行的结果是一致的,但不保证程序中的各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后执行顺序来保证。

与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序优化。

2、java内存模型

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

a.主内存和工作内存

  • Java内存模型主要目标:定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
  • 此处的变量(Variable)与Java编程中的变量略有区别,它包括实例变量/静态字段和构成数组对象的元素,不包括局部变量和方法参数(线程私有)
  • 为获得较好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交换,也没有限制即时编译器调整代码执行顺序这类权利。

Java内存模型规定所有变量都存储在主存(Main Memory)中(虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory),线程的工作内存保存了被线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取/赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主存来完成。

注意:这里所讲的主内存、工作内存与前面讲解Java内存区域中的Java堆、栈、方法区等不适同一个层次划分,两者无任何关系。

如果两者一定要勉强对应起来,那从变量/主内存/工作内存的定义来看,主内存主要对应于Java堆中对象的实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低的层次来说,主存就是硬件的内存,而为获取更好的运算速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存。

b.内存间交互操作

  1. lock,锁定:作用于主内存的变量,把一个变量标识为一条线程独占的状态;
  2. unlock,解锁:作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  3. read,读取:作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,方便随后的load动作使用;
  4. load,载入:作用于工作内存的变量,把read操作从主内存中得到的变量值放到工作内存的变量副本中;
  5. use,使用:作用域工作内存的变量,把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个需要使用到变量的值得字节码指令时会执行这个操作;
  6. assign,赋值:作用于工作内存的变量,把一个从执行引擎收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
  7. store,存储:作用于工作内存的变量,把工作内存中一个变量的值传送给主内存中,方便随后的write操作使用;
  8. write,写入:作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存变量中。

注意: Java内存模型只要求上述两个操作必须按照顺序执行,而没有保证是连续执行。也就是说,read 和 load 之间、store 和 write 之间是可插入其他指令的。如对内存中的变量a、b 进行访问时,一种可能出现的顺序是 :read a、read b、load b、load a 。

除此之外,Java内存模型还规定在执行上述8种操作需满足的规则:

  1. 不允许read和load、store和write操作单一出现,即不允许一个变量从主内存读取了但工作内存不接收,或者工作内存发起回写了但主内存不接收;
  2. 不允许一个线程丢弃最近的assign操作,即变量在工作内存中改变了之后必须要把变化同步到主内存中;
  3. 不允许一个线程无原因的把数组从线程的工作内存同步回主内存(性能考量);
  4. 一个新变量只能在主内存中诞生,不允许在工作内存中直接使用一个未初始化的变量;
  5. 一个变量同一时刻只允许一条线程对其lock操作,但lock操作可以被同一条线程执行多次,多次执行lock后需要执行相同次数的unlock操作,变量才会解锁;
  6. 如果对一个变量进行lock操作,会清空工作内存中该变量的值,在执行引擎使用这个变量前需要重新执行load和assign操作;
  7. 如果一个变量事先没有被lock锁定,那不允许unlock操作出现,也不允许unlock其他一个线程锁定的线程;
  8. 对一个变量执行unlock操作之前,必须先把该变量同步回主内存中。

c.对于volatile型变量的特殊规则

可见性

当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量做不到这一点,其在线程间传递需要通过主内存来完成

禁止指令重排序

  • 普通变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致.
  • 从硬件架构上讲,指令重排序是指cpu采用了允许将多条指令不按程序规定的顺序分开发送给相应的电路单元处理。但并不是指令任意重排,cpu需要能正确处理指令依赖情况以保障程序能得到正确的执行结果。例如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序是不能重排的,但是指令3可以重排到指令1和2之前或者中间,只要保证cpu执行后面依赖A、B值的操作时能获得正确的A、B值即可。所以在本内CPU中,重排序看起来依然是有序的。因此,lock指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。重排序会破坏了多线程程序的语义,因此才需要诸如volatile这样的技术来禁止重排序!

volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,仍然需要通过加锁保证原子性:

(1)运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值;

(2)变量不需要与其他的状态变量共同参与不变约束。

volatile指令操作,它的作用相当于一个内存屏障(Memory Barrier),指重排序时不能把后面的指令重排序到内存屏障之前的位置。当两个或以上的CPU访问同一块内存时,需要内存屏障来保证一致性。

d.对于LONG和Double类型变量的特殊规则

Java内存模型要求lock、unlock、read、load、use、assign、store、write这八个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据类型的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这四个操作的原子性,这点就是所谓的long和double的非原子协定。

上面也就是说没有被volatile修饰的64位数据类型的变量如果被多个线程共享,并且同时对其进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也非其他线程修改的“半个变量”的数值。

不过读取到“半个变量”的情况非常罕见(在目前的商用Java虚拟机中不会出现),因为Java内存模型虽然允许虚拟机不把long和double变量的读写实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这样实现。

在实际的开发中,目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到的long和double变量专门声明为volatile。

e.原子性、可见性与有序性

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

1、原子性:
  • 定义:由于Java内存模型来直接保证的原子性变量操作包括 read,load,assign,use,store和write,我们大致认为基本数据类型的访问读写数据是具备原子性的。

  • 更大范围的原子性保证:如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock 和 unlock 操作来满足这些需求,尽管虚拟机没有把lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作。

  • synchronized关键字:monitorenter 和 monitorexit 这两个字节码指令反映到java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

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

  • 定义:Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此

  • 普通变量与 volatile变量的区别:volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,所以volatile保证了多线程操作时变量的可见性,普通变量则不能保证这一点。

  • synchronized 和 final关键字:除了volatile关键字外,Java还有两个关键字实现可见性: synchronized 和 final。

  • synchronized同步块的可见性: 是由对一个变量执行unlock 操作前,必须先把此变量同步回主内存中;

  • final关键字的可见性:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this 的引用传递出去(this引用传递很危险,其他线程很有可能通过此引用访问到“初始化了一半”的对象),那在其他线程中就能看见final 字段的值。

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

  • volatile和 synchronized关键字保证了线程间操作的有序性:

  1. volatile关键字本身就包含了禁止指令重排序的语义。
  2. synchronized则是由 一个变量在同一时刻只允许一条线程对其进行lock 操作这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

f.先行发生原则

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

  • 程序次序规则:同一个线程中,按照程序代码顺序。
  • 管程锁定规则:一个unlock操作先行发生与后面对同一个锁的lock操作。
  • volatile变量规则:volatile变量写操作先行发生于后面这个变量的读操作。
  • 线程启动规则:Thread对象start方法先行发生于此线程的每个操作。
  • 线程终止规则:Thread对象所有操作先行发生于对此对象的终止检测,通过Thread.join()方法结束,Thread.isAlive()返回值检测。
  • 线程中断规则:对线程interrupt()方法调用先行发生于被中断的代码检测到中断事件的发生,通过Thread.interrupt()方法检测。
  • 对象终结规则:一个对象初始化完成先行发生于它的finalize()方法的开始。
  • 传递性:如果A先行发生于B,B先行发生于C,那么A先行发生于C。

3、Java与线程

线程其实是比进程更轻量级的调度执行单位。线程的引入,可以把一个检查的资源分配和执行调度分开,各个线程既可以共享资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)。

a.线程的实现

实现线程的3种方式分别是:使用内核线程实现;使用用户线程实现;使用用户线程加轻量级进程混合实现。

内核线程:

定义:直接由操作系统内核支持的线程。

原理:

  • 内核线程(Kernel-Level Thread): 就是直接由操作系统内核(下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。
  • 多线程内核(Multi-Threads Kernel):每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多个事情,支持多线程的内核就叫多线程内核。
  • 轻量级进程(Light Weight Process):程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程:轻量级进程就是我们通常意义上讲的线程。
  • 轻量级进程与内核线程之间关系:由于每个轻量级线程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程间1:1 的关系称为一对一的线程模型,

优点:每个轻量级进程都由一个内核线程支持,因此每个都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞,也不会影响整个进程继续工作。

缺点:由于基于内核线程实现,所以各种线程操作(创建、析构及同步)都需要进行系统调用,代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换;另外,一个系统支持轻量级进程的数量是有限的。

用户线程:

  • 定义:广义上认为一个线程不是内核线程就是用户线程;狭义上认为用户线程指的是完全建立在用户空间的线程库上,而系统内核不能感知线程存在的实现。
  • 优点:由于用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助,甚至可以不需要切换到内核态,所以操作非常快速且低消耗的,且可以支持规模更大的线程数量。
  • 缺点:由于没有系统内核的支援,所有的线程操作都需要用户程序自己处理,线程的创建、切换和调度都是需要考虑的问题,实现较复杂。
  • 一对多的线程模型进程:进程与用户线程之间1:N的关系

用户线程和轻量级进程混合:

  • 定义:既存在用户线程,也存在轻量级进程。
  • 优点:用户线程完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发;操作系统提供支持的轻量级进程作为用户线程和内核线程之间的桥梁,可以使用内核提供的线程调度功能及处理器映射,且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。
  • 多对多的线程模型:用户线程与轻量级进程的数量比不定,即用户线程与轻量级进程之间N:M的关系

Java线程:

JDK1.2前使用基于称为“绿色线程”的用户线程实现的,1.2以后替换为基于操作系统原生线程模型实现。 Sun JDK来说,它的Windows版本与Linux版本都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程中,因为Windows和Linux系统提供的线程模型就是一对一的

b.Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种:

协同式线程调度和抢占式线程调度。

协同式线程调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。

  • 优点:实现简单,无线程同步问题(因为都是要把自己的事情干完才会进行线程切换);

  • 缺点:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序会一直阻塞在那里。

抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的就是抢占式调度。

c.Java线程优先级

虽然Java 线程调度是系统自动完成的,但我们还是可以建议系统给某些线程多分配一点执行时间,另外一些线程则可以少分配一点——这项操作可以通过设置线程优先级来完成。Java语言一共设置了10个级别的优先级,在两个线程同时处于Ready状态,优先级越高的线程越容易被系统选择执行。

不过线程优先级并不是太靠谱,原因是因为Java的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于 操作系统,虽然现在很多os 都提供了线程优先级,但不见得和 能与 java线程的优先级一一对应。如 Solaris中有 2^32 种优先级,而windows只有7种 。

d.状态切换

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

  2. 运行(Runnable): Runable包括了os 线程状态中的 Running 和 Ready,也就是处于 此状态的线程有可能正在执行,也有可能正在等待着CPU 为它分配执行时间。

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

1
2
3
4
5
没有设置Timeout参数的Object.wait()方法;

没有设置Timeout参数的 Thread.join() 方法;

LockSupport.park() 方法;
  1. 限期等待(Timed Waiting):处于这种状态的线程也不会被分配CPU 执行时间,不过无需等待被其他线程显式唤醒,在一定时间之后,它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
1
2
3
4
5
6
7
8
9
Thread.sleep() 方法;

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

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

LockSupport.parkNanos() 方法;

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

  2. 结束(Terminated):已经终止线程的线程状态,线程已经结束执行。

二、线程安全与锁优化

1、线程安全

代码本身封装了所有必要的正确性保障手段(如互斥同步),令调用者无须关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。

a 不可变

(1)定义:

  • 不可变对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要采取任何的线程安全保障措施。
  • 如果共享数据是一个基本数据类型,那么只要在定义时使用 final 关键字修饰它就可以保证它是不可变的。
  • 如果共享的是一个对象,需要保证对象行为不会对其状态产生任何影响。

例如 java.lang.String类的对象:它是一个典型的不可变对象,调用它的substring(), replace(), concat() 这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。

(2)保证对象行为途径

途径有多种,其中最简单的就是把对象中带有状态的变量都声明为final,这样在构造函数之后,它就是不可变的。

b.绝对线程安全

(1)定义

一个类要达到“不管运行环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。

例如 java.util.Vector 是一个线程安全的容器,因为它的add()方法、get()方法、size()方法这些方法都是被synchronized修饰的,尽管效率低下,但确实是安全的。可以即使如此并不意味着调用它的时候不需要同步手段了.

但是有时候还会抛出异常,抛出异常的原因:因为如果另一个线程恰好在错误的时间里删除了一个元素,导致序号i 已经不再可用的话,再用i 访问数组就会抛出一个 ArrayIndexOutOfBoundsException。

c. 相对线程安全

(1)定义

相对线程的安全就是通常意义上所讲的线程安全,它需要保证对这个对象单独操作是线程安全的。开发人员在调用的时候不需要做额外保障措施,但是对于一些特定顺序连续调用,就需要在调用端使用额外的同步手段来保证调用正确性。

d. 线程兼容

(1)定义

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。平常所说的一个类不是线程安全,绝大多数指这种情况。

Java API 大部分类属于线程兼容的,如之前的Vector和 HashTable相对应的集合类ArrayList 和 HashMap等。

e. 线程对立

指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。

注意:由于java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常是有害的,应当尽量避免。

例如Thread类的suspend() 和 resume() 方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都是存在死锁风险的。

2、线程安全的实现方法

a. 互斥同步(Mutual Exclusion & Synchronization)

(1)定义

互斥同步是常见的并发正确性保障手段。

同步:是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻被一个线程使用。

互斥:互斥是实现同步的一种手段。临界区,互斥量和信号量都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

(2)synchronized关键字

最基本的互斥同步手段就是 synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这个两个字节码指令,这两个字节码都需要一个 reference类型的参数来指明要锁定和解锁的对象。

如果java程序中的synchronized明确指定了对象参数,那就是这个对象的reference。

如果没有明确指定,那就根据 synchronized修饰的实例方法还是类方法,去取对应的对象实例或Class 对象来作为锁对象。

(3)monitorenter和monitorexit 指令执行

根据虚拟机规范的要求:在执行monitorenter指令时,如果这个对象没有锁定或当前线程已经拥有了那个对象的锁,锁的计数器加1,相应的,在执行 monitorexit 指令时会将锁计数器减1;当计数器为0时,锁就被释放了。其中有两点需要注意:

synchronized同步块对同一条线程来说是可重入的, 不会出现自己把自己锁死的问题。

同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。

(4)重入锁(ReentrantLock)

除了synchronized之外,还可以使用 java.util.concurrent 包中的重入锁来实现同步。

synchronized 和 ReentrantLock 的区别: 一个表现为 API 层面的互斥锁(lock() 和 unlock() 方法配合 try/finally 语句块来完成),另一个表现为 原生语法层面的互斥锁。

(5)ReentrantLock新增的高级功能

主要有3项:等待可中断,可实现公平锁, 以及锁可以绑定多个条件。

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

公平锁:指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;

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

b. 非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,称为阻塞同步。

(1)定义

基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了。如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施,这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为 非阻塞同步。

(2)硬件指令集

为什么使用乐观并发策略需要“硬件指令集的发展”才能进行呢?因为我们需要操作和冲突检测这两个步骤具备原子性,靠什么来保证呢?

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

  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并交换(Compare-and-Swap,下文简称 CAS)
  • 加载链接/ 条件存储(Load-Linked/Store-Conditional,下文简称 LL/SC)

(3)CAS 操作避免阻塞同步

如何使用CAS 操作来避免阻塞同步:原子类。

(4)CAS操作(比较并交换操作)的ABA问题

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就说它的值没有被其他线程改变过了吗? 如果在这段期间它的值曾经被改为了B,之后又改回了A,那CAS操作就会误认为它从来没有被改变过,这个漏洞称为 CAS操作的 ABA问题。

(5)解决方法

J.U.C 包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。不过目前来说这个类比较鸡肋, 大部分情况下 ABA问题 不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

c. 无同步方案

如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的,下面介绍其中的两类。

(1)可重入代码(Reentrant Code)

也叫作纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。

所有的可重入代码都是线程安全的;

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

(2)线程本地存储(Thread Local Storage)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能够保证在同一线程中执行? 如果能保证,我们就可以把共享数据的可见范围限制在同一个线程内,这样,无需同步也可以保证线程间不出现数据争用问题。

3、锁优化

a.自旋锁与自适应自旋

(1)定义

若物理机有一个以上的处理器,能让两个或以上的线程同时执行,就可以让后面请求锁的线程“稍等”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。

为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

(2)自旋等待时间限度

自旋等待不能代替阻塞,它本身虽避免了线程切换的开销,但仍要占用处理器时间。因此时间占用越短,效果越好,反之自旋的线程只会白白消耗处理器资源,带来性能浪费。

自旋等待的时间必须要有一定的限度, 如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10,用户可以用参数 -XX:PreBlockSpin 来更改。

(3)自适应自旋锁

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

  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。

  • 如果对于某个锁,自旋很少成功获得过, 那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

b.锁消除

(1)定义

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

(2)锁消除的主要判定依据

来源于逃逸分析的数据支持,如果判定在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

(3)Java程序中“默认”存在的同步操作

程序员应该很清楚,怎么会明知道不存在数据争用的情况下要求同步呢?需要注意,许多同步措施不是程序员自己加的,而是某些而Java程序自带的。

c.锁粗化

(1)同步操作数量尽可能少原则

在编写代码时,总是推荐同步块的作用范围尽可能小——-只在共享数据的实际作用域中才进行同步,这是为了同步操作数量尽可能少,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

(2)问题

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

(3)解决方法——锁粗化

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

d.轻量级锁

(1)定义和作用

定义:“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言,传统的锁机制就称为“重量级”锁。

目的:轻量级锁并非用来代替重量级锁,本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

(2)HotSpot虚拟机的对象头

要理解轻量级锁,首先需要了解HotSpot虚拟机的对象头,分为两部分信息:

第一部分:用于存储对象自身的运行时数据,如哈希码,GC分代年龄等;这部分数据的长度在32位和64位的虚拟机中分别为 32bit 和 64bit,官方称它为 Mark Word,它是实现轻量级锁和偏向锁的关键。
第二部分:用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
(3)HotSpot 虚拟机对象头Mark Word

对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会工具对象的状态复用自己的存储空间。

(4)轻量级锁的加锁过程

在代码进入同步块的时候,轻量级锁的加锁过程如下:

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

2)然后,虚拟机将使用CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record的指针。

3)如果这个更新工作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为 00,即表示 此对象处于轻量级锁定状态。

4)如果这个更新失败了,虚拟机首先会检查对象的Mark Word 是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象以及被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为 10,Mark Word中存储的就是指向重量级(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

(5)轻量级锁的解锁过程

1)如果对象的Mark Word仍然指向着线程的锁记录,那就用CAS 操作把对象当前的Mark Word 和 线程中复制的 Dispatched Mard Word替换回来。

2)如果替换成功,整个同步过程就完成。

3)如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

(6)小结

轻量级锁能提升程序同步性能的依据是: 对于绝大部分的锁,在整个同步周期内都是不存在竞争的。

如果没有竞争,轻量级锁使用CAS 操作避免了使用互斥量的开销。

如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS 操作,因此在有竞争的情况下, 轻量级锁会比传统的重量级锁更慢。

e.偏向锁

(1)定义与目的

目的:消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。

定义: 如果说轻量级锁是在无竞争的情况使用CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS 操作都不做了。

(2)偏向锁中的“偏”

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

(3)偏向锁的原理

若当前虚拟机启用了偏向锁,那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为01, 即偏向模式。同时使用CAS 操作把获取到这个锁的线程的ID 记录在对象的 Mark Word之中,如果 CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。

(4)偏向锁、轻量级锁的状态转换及对象Mark Word的关系

当有另一个线程去尝试获取这个锁时,偏向模式就结束了。根据锁对象目前是否处于被锁定的状态, 撤销偏向后恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。

偏向锁、轻量级锁的状态转换及对象Mark Word的关系如图:

10、字节码执行引擎

发表于 2019-07-10 | 更新于 2019-07-09 | 分类于 看书笔记 , 深入理解Java虚拟机

所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。本节将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。

一、运行时栈帧结构(重点)

栈帧(Stack Frame) 是用于支持虚拟机方法调用和方法执行的数据结构,它是虚拟机运行时数据区中虚拟机栈(Virtual Machine Stack)的栈元素。

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始到执行完成的过程,就对应一个栈帧在虚拟机栈里从入栈到出栈的过程。

在编译程序代码的时候,栈帧需要多大的局部变量表、多深的操作数栈都已经完全确定了,因此一个栈帧需要分配多少内存不会受到运行期变量数据的影响。

活动线程中只有栈顶的栈帧是有效的,称为当前栈帧,执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

在活动线程中,只有位于栈顶的栈帧才是有效的,成为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

Java 虚拟机每调用一个 Java 方法,便会创建一个栈帧。

这种栈帧有两个主要的组成部分,分别是局部变量区,以及字节码的操作数栈。这里的局部变量是广义的,除了普遍意义下的局部变量之外,它还包含实例方法的“this指针”以及方法所接收的参数。

1、局部变量表

1.1.作用

局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。程序编译完成时,就在方法的的Code属性的max_locals数据项中定义了该方法的所需分配的局部变量表的最大容量。

  • 在 Java 虚拟机规范中,局部变量区等价于一个数组,并且可以用正整数来索引。除了 long、double 值需要用两个数组单元来存储之外,其他基本类型以及引用类型的值均占用一个数组单元。

  • 也就是说,boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的,和引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。

当然,这种情况仅存在于局部变量,而并不会出现在存储于堆中的字段或者数组元素上。对于 byte、char 以及 short 这三种类型的字段或者数组单元,它们在堆上占用的空间分别为一字节、两字节,以及两字节,也就是说,跟这些类型的值域相吻合。

1.2.存储

  1. 局部变量表的容量以变量槽(slot)为最小单位,一个slot的内存占用并不确定,但每个slot应该能存放下boolean、byte、char、short、int、float、reference或者returnAddress类型的数据,这些可以使用32位或者更小的物理内存来存放,但允许slot的长度随着处理器、操作系统或者虚拟机的不同而发生变化。

  2. 虚拟机对reference类型的要求:一是从此引用中直接或者间接地查找到对象在Java堆中的数据存放的起始索引地址;二是此引用中直接或者间接地查找到对象所属数据类型在方法区中的存储的类型信息。

  3. 对于64位数据类型(long、double),虚拟机以高位对齐的方式为其分配两个连续的slot空间。

1.3.局部变量表的使用

  • 虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型,就代表会同时使用n和n+1这两个Slot。

  • 为了节省栈帧空间,局部变量Slot可以重用,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码PC计数器的值超出了某个变量的作用域,那么这个变量的Slot就可以交给其他变量使用。这样的设计会带来一些额外的副作用,比如:局部变量表作为 GC Roots的一部分,如果局部变量表中对象的引用一直存在或者没有被替换,该对象就不会被GC 回收。

局部变量不会有“准备阶段”,即不会赋予它初始值,一个局部变量如果定义了但是没有赋初始值是不能使用的。

2、操作数栈

操作数栈也常被称作操作栈,他是后入先出的栈。操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。
32位的数据类型所占栈容量为1,64位数据类型的栈容量为2。在方法执行的时候,操作数栈的深度不会超过在max_stacks中设定的最大值。

Java 虚拟机的算数运算几乎全部依赖于操作数栈。也就是说,我们需要将堆中的 boolean、byte、char 以及 short 加载到操作数栈上,而后将栈上的值当成 int 类型来运算。

  • 当一个方法刚刚开始执行时,方法的操作数栈为空,在方法执行过程中,字节码指令向操作数栈写入和提取内容,也就是出栈、入栈操作。

  • 在概念模型中,一个活动线程中两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。

3、动态链接

每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,这个引用为了支持调用过程中动态链接(Dynamic linking)。

字节码中方法调用指令是以常量池中的指向方法的符号引用为参数的,有一部分符号引用会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为 静态解析,另外一部分在每次的运行期间转化为直接引用,这部分称为动态连接。

4、方法返回地址

当一个方法被执行后,有两种方式退出这个方法:

  • 第一种是执行引擎遇到任意一个方法返回的字节码指令,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
  • 另外一种是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理(即本方法异常处理表中没有匹配的异常处理器),就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。 注意:这种退出方式不会给上层调用者产生任何返回值。

无论采用何种退出方式,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

5、附加信息

虚拟机规范允许虚拟机实现向栈帧中添加一些自定义的附加信息,例如与调试相关的信息等。

二、方法调用
方法调用阶段唯一的任务就是确定被调用方法的版本(调用的是哪一个方法),暂时还不涉及方法内部的具体运行过程。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于直接引用)这个特性为java带来了更强大的动态扩展能力。

解析

所有方法调用中的目标方法在Class文件里面都是一个常量池的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的条件是方法在程序真正运行之前就有一个可确定的版本,并且这个方法的调用版本在运行期是不可变的。即调用目标在程序代码写好、编译器进行编译时就必须确定下来。这种调用称为解析。

符合”编译器可知,运行期不变“的方法,主要是静态方法和私有方法。静态方法与类型直接相关,私有方法在外部不可能被访问,因此这两种方法不可能通过继承或别的方法重写其他版本,因此适合在类加载阶段进行解析。

在Java虚拟机中提供了5条方法调用字节码指令:

invokestatic : 调用静态方法
invokespecial: 调用实例构造器方法、私有方法、父类方法
invokevirtual: 调用所有的虚方法
invokeinterface: 调用接口方法,会在运行时在确定一个实现此接口的对象
invokedynamic: 先在运行时动态解析出点限定符所引用的方法, 然后再执行该方法,在此之前的4条调用命令的分派逻辑是固化在Java虚拟机内部的, 而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合条件的有静态方法、私有方法、实力构造器、父类方法4类。它们在类加载的时候就会把符号引用解析为该方法的直接引用。 这些方法称为非虚方法。final修饰的方法也是非虚方法。

解析是个静态的过程,编译期间就完全确定,在类加载的解析阶段会把涉及的符号引用全部转为直接引用,不会延迟到运行期再去完成

三、基于栈的字节码解释执行引擎

虚拟机如何调用方法的内容已经讲解完毕,现在我们来探讨虚拟机是如何执行方法中的字节码指令。

1、解释执行

Java语言经常被人们定位为“解释执行”语言,在Java初生的JDK1.0时代,这种定义还比较准确的,但当主流的虚拟机中都包含了即时编译后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出来了直接生成本地代码的编译器[如何GCJ(GNU Compiler for the Java)],而C/C++也出现了通过解释器执行的版本(如CINT),这时候再笼统的说“解释执行”,对于整个Java语言来说就成了几乎没有任何意义的概念。

2、基于栈的指令集与基于寄存器的指令集

Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对应的另一套常用的指令集架构是基于寄存器的指令集,最典型的就是X86的地址指令集,说的通俗一下,就是现在我们主流的PC机中直接支持的指令集架构,这些指令集依赖寄存器工作。

例子:如下分别使用两种指令集计算“1+1”的结果:

基于栈的指令集:

1
iconst_1 iconst_1 iadd istore_0

两条 iconst_1 指令连续把两个常量1压入操作栈后,
iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶值放到局部变量表的第0个Slot中。

基于寄存器:

1
mov eax,1 add eax,1

mov指令把EAX寄存器的值设为1,然后add指令再把值加1,结果就保存在EAX寄存器里面。

两套指令集的优缺点:

基于栈的指令集主要的优点就是可移植性,不会受硬件的不同而受影响。而基于寄存器的指令集,程序直接依赖这些硬件寄存器,不同的硬件设备,则不可避免受到约束。而使用栈架构的指令集,用户程序不会直接使用这些寄存器,而是由虚拟机来完成与寄存器的交互,从而避免直接与硬件交互。但基于栈指令集的主要缺点是执行速度相对来说会稍慢一些。而相对的基于寄存器指令集的执行速度会相对较优。

3、基于栈的解释器执行过程

通过如下代码解释基于栈的解释器执行过程:

1
2
3
4
5
6
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}

使用javap命令后得如下反编译代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public int calc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn

javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,根据这些信息总共可绘制7张图来描述程序执行过程中的代码、 操作数栈和局部变量表的变化情况:

四、字节码介绍

Java虚拟机指令由一个字节长度的、代表某种特定操作含义的数字(操作码)以及跟随其后的零至多个代表此操作所需的参数构成。

1、字节码与数据类型

Java虚拟机的指令由一个字节长度,代表着某种特定操作含义的数字(称为操作码)以及跟其随后的零至多个代表此操作所需参数(称为操作数)而构成。Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数指令都不包含操作数。因为字节码指令只有一个字节,所以指令集的操作码总数不可能超过256条。

在Java虚拟机中,大多数的指令都包含了其对操作所对应的数据类型信息。对于大部分与数据类型相关的字节码指令,他们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务。i代表int l代表long,s代表short,b代表byte,c代表char,f代表float,a代表reference.

2、加载和存储指令

加载存储指令用于将数据在帧栈中的局部变量表和操作数栈进行来回传输。

①将一个局部变量加载到操作栈:

1
iload,iload<n>,lload,lload<n>,fload,fload<n>,dload,dload<n>,aload,aload<n>

②将一个数值从操作数栈存储到局部变量表中:

1
istore,istore<n>,lstore,lstore<n>,fstore,fstore<n>,dstore,dstore<n>,adore,adore<n>

③将一个常量加载到操作数栈中:

1
bipush,sipush,ldc,ldc_w,ldc2_w,aconst_null,iconst_m1,iconst_<i>,fconst_<i>,dconst_<i>.

④扩充局部变量的访问索引的指令。wide.

3、运算指令

运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。注意:由于没有直接支持byte,short,char和boolean类型的算术运算指令,对这种类型直接转换为int进行运算。特别需要注意的是:数据运算可能会导致溢出对象,Java虚拟机在整数没有定义数据异常,所以整数异常不报异常,注意在编程中范围。

  • 加法指令:iadd,ladd,fadd,dadd 减法指令:isub,lsub,fsub,dsub
  • 乘法指令:imul,lmul,fmul,dmul
  • 除法指令:idiv,ldiv,fdiv,ddiv
  • 求于指令:irem,lrem,frem,drem
  • 取反指令:ineg,lneg,fneg,dneg
  • 位移指令:iishl,lishl,fishl,dishl 按位或指令:ior,lor
  • 按位与指令:land,land 按位异或指令:ixor,lxor
  • 局部变量自增指令:inc 比较指令:dcmpg,dcmpl,fcmpg,fcmpl,lcmp

注意:

  1. Java虚拟机在处理浮点数运算时,不会抛出任何运行时异常,当一个操作产生溢出时,将会使用有符号的无穷大来表示。操作结果没有明确的数学定义的话,将会时候NaN值来表示。

  2. 在处理整型数据时,只有除法指令(idiv和ldiv)以及求余指令(irem和lrem)出现除数为零时会导致虚拟机抛出异常

4、类型转换指令

类型转换指令可以将两种不同的数值类型进行相互转换,一般用于实现用户代码中的显式类型转换操作。

一、

  1. int类型到long、float或者double类型
  2. long类型到float、double类型
  3. float类型到double类型

二、

  1. 宽化类型转换指令包括:i2l, i2f, i2d, l2f, l2d, f2d
  2. 窄化类型转换:指令包括有:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级,转换过程很可能会导致数值丢失精度。

5、对象创建与访问指令

虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。

  1. new :创建类实例的指令。
  2. newarray、anewarray、multianewarray:创建数组的指令。
  3. getstatic、putstatic、getfield、putfield:访问类字段(类变量)和实例字段(实例变量)的指令。
  4. baload、caload、saload、iaload、laload、faload、daload、aaload:把一个数组元素加载到操作数栈的指令。
  5. bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore:把一个操作数栈的值存储到数组元素中的指令。
  6. arraylength:取数组长度的指令。
  7. instanceof、checkcast:检查类实例类型的指令。

6、控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定指令而不是控制转移指令的下一条指令继续执行程序。控制转移指令包括有:

  1. ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq、if_acmpne:条件分支。
  2. tableswitch、lookupswitch:复合条件分支。
  3. goto、goto_w、jsr、jsr_w、ret:无条件分支。

7、操作数栈管理指令

java虚拟机提供了一些用于直接操作操作数栈的指令

  1. pop、pop2:将操作数栈的栈顶一个或两个元素出栈。
  2. dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2:复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶。
  3. swap:将栈最顶端两个数值互换。

8、控制转移指令

  1. ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq、if_acmpne:条件分支。
  2. tableswitch、lookupswitch:复合条件分支。
  3. goto、goto_w、jsr、jsr_w、ret:无条件分支。

9、方法调用和返回指令

方法调用:

  1. invokestatic:调用静态方法。
  2. invokespecial:调用实例构造器方法、私有方法和父类方法。
  3. invokevirtual:调用所有的虚方法。非虚方法以外的都是虚方法,非虚方法包括使用invokestatic、invokespecial调用的方法和被final修饰的方法。
  4. invokeinterface:调用接口方法,运行时再确定一个实现此接口的对象。
  5. invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。
    返回值指令:
    ireturn(返回值是boolean、byte、char、short、int)、lreturn、freturn、dreturn、areturn:方法返回指令。

10、异常处理指令

在程序中显式抛出异常的操作会由athrow指令实现,除了这种情况,还有别的异常会在其他Java虚拟机指令检测到异常状况时由虚拟机自动抛出。

athrow :显式抛出异常指令。

11、同步指令

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。

  • 同步一段指令集序列通常是由Java语言中的synchronized块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义.

  • monitorenter、monitorexit:支持synchronized语句块语义的指令。

五、指令

具体来说,Java 字节码中与调用相关的指令共有五种。

  1. invokestatic:用于调用静态方法。
  2. invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
  3. invokevirtual:用于调用非私有实例方法。
  4. invokeinterface:用于调用接口方法。
  5. invokedynamic:用于调用动态方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface 客户 {
boolean isVIP();
}

class 商户 {
public double 折后价格 (double 原价, 客户 某客户) {
return 原价 * 0.8d;
}
}

class 奸商 extends 商户 {
@Override
public double 折后价格 (double 原价, 客户 某客户) {
if (某客户.isVIP()) { // invokeinterface
return 原价 * 价格歧视 (); // invokestatic
} else {
return super. 折后价格 (原价, 某客户); // invokespecial
}
}
public static double 价格歧视 () {
// 咱们的杀熟算法太粗暴了,应该将客户城市作为随机数生成器的种子。
return new Random() // invokespecial
.nextDouble() // invokevirtual
+ 0.8d;
}
}

1. 虚方法调用

Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指令,而接口方法调用都会 被编译成 invokeinterface 指令。这两种指令,均属于Java 虚拟机中的虚方法调用。

在绝大多数情况下,Java 虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法。这个过程我们称之为动态绑定。那么,相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时。

在 Java 虚拟机中,静态绑定包括用于调用静态方法的 invokestatic 指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的 invokespecial 指令。如果虚方法调用指向一个标记为 final 的方法,那么 Java 虚拟机也可以静态绑定该虚方法调用的目标方法。

Java 虚拟机中采取了一种用空间换取时间的策略来实现动态绑定。它为每个类生成一张方法表,用以快速定位目标方法。

2. 方法表

类加载的准备阶段,它除了为静态字段分配内存之外,还会构造与该类相关联的方法表。

这个数据结构,便是 Java 虚拟机实现动态绑定的关键所在。下面我将以 invokevirtual 所使用的虚方法表(virtual method table,vtable)为例介绍方法表的用法。invokeinterface 所使用的接口方法表(interface method table,itable)稍微复杂些,但是原理其实是类似的。

方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。

这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:

其一,子类方法表中包含父类方法表中的所有方法;
其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。
我们知道,方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。

在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。

使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。相对于创建并初始化 Java 栈帧来说,这几个内存解引用操作的开销简直可以忽略不计

那么我们是否可以认为虚方法调用对性能没有太大影响呢?

其实是不能的,上述优化的效果看上去十分美好,但实际上仅存在于解释执行中,或者即时编译代码的最坏情况中。这是因为即时编译还拥有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)。

3. 内联缓存

内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。

即:

Java 虚拟机中的即时编译器会使用内联缓存来加速动态绑定。Java 虚拟机所采用的单态内联缓存将纪录调用者的动态类型,以及它所对应的目标方法。

当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。否则,Java 虚拟机将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定。

在针对多态的优化手段中,我们通常会提及以下三个术语。

  1. 单态(monomorphic)指的是仅有一种状态的情况。
  2. 多态(polymorphic)指的是有限数量种状态的情况。二态(bimorphic)是多态的其中一种。
  3. 超多态(megamorphic)指的是更多种状态的情况。通常我们用一个具体数值来区分多态和超多态。在这个数值之下,我们称之为多态。否则,我们称之为超多态。

对于内联缓存来说,我们也有对应的单态内联缓存、多态内联缓存和超多态内联缓存。单态内联缓存,顾名思义,便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。

  • 多态内联缓存则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。

  • 一般来说,我们会将更加热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,Java 虚拟机只采用单态内联缓存。

  • 前面提到,当内联缓存没有命中的情况下,Java 虚拟机需要重新使用方法表进行动态绑定。对于内联缓存中的内容,我们有两种选择。一是替换单态内联缓存中的纪录。这种做法就好比 CPU 中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。

  • 因此,在最坏情况下,我们用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。

  • 另外一种选择则是劣化为超多态状态。这也是 Java 虚拟机的具体实现方式。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存纪录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。

  • 虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。

  • 对于极其简单的方法而言,比如说 getter/setter,这部分固定开销占据的 CPU 时间甚至超过了方法本身。此外,在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性.

六、调用指令的符号引用

在编译过程中,我们并不知道目标方法的具体内存地址。因此,Java 编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。

符号引用存储在 class 文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。

对于非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找。

  1. 在 C 中查找符合名字及描述符的方法。
  2. 如果没有找到,在 C 的父类中继续搜索,直至 Object 类。
  3. 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足 C 与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。
  4. 从这个解析算法可以看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。

对于接口符号引用,假定该符号引用所指向的接口为 I,则 Java 虚拟机会按照如下步骤进行查找。

  1. 在 I 中查找符合名字及描述符的方法。
  2. 如果没有找到,在 Object 类中的公有实例方法中搜索。
  3. 如果没有找到,则在 I 的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致。

经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。

七、公有设计和私有实现

Java虚拟机规范描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集。这些内容与硬件、操作系统及具体的Java虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把他们看作是程序在各种Java平台实现之间互相安全的交互的手段。

理解公有设计与私有实现之间的分界线是非常有必要的,Java虚拟机实现必须能够读取Class文件并精确实现包含在其中的Java虚拟机代码的语义。拿着Java虚拟机规范一成不变的逐字实现其中要求的内容当然是一种可行的途径,但一个优秀的虚拟机实现,在满足虚拟机规范的约束下对具体实现做出修改和优化也是完全可行的,并且虚拟机规范中明确鼓励实现者这样做。只要优化后Class文件依然可以被正确读取,并且包含在其中的语义能得到完整的保持,那实现者就可以选择任何方式去实现这些语义,虚拟机后台如何处理Class文件完全是实现者自己的事情,只要他在外部接口上看起来与规范描述的一致即可。

虚拟机实现者可以使用这种伸缩新来让Java虚拟机获得更高的性能、更低的内存消耗或者更好的可移植性,选择哪种特性取决于Java虚拟机实现的目标和关注点是什么。虚拟机实现的方式主要有以下两种:

  • 将输入的Java虚拟机代码在加载或执行时翻译成另外一种虚拟机的指令集。
  • 将输入的Java虚拟机代码在加载或执行时翻译成宿主CPU的本地指令集(即JIT代码生成技术)。

精确定义的虚拟机和目标文件格式不应当对虚拟机实现者的创造性产生太多的限制,Java虚拟机因被设计成可以允许有众多不同的实现,并且各种实现可以在保持兼容性的同时提供不同的、新的、有趣的解决方案。

9、静态分派和动态分派

发表于 2019-07-10 | 更新于 2019-07-09 | 分类于 看书笔记 , 深入理解Java虚拟机

1、基础知识点

1.1 分派

定义:确定执行哪个方法的过程
分类:静态分派 & 动态分派

a. 疑问

方法的执行不是取决于代码设置中的执行对象吗?为什么还要选择呢?

b. 回答

  • 若 一个对象对应于多个方法 时,就需要进行选择了
  • Java中的特性:多态,即重写 & 重载。

1.2 变量的静态类型 & 动态类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Test { 

static abstract class Human {
}

static class Man extends Human {
}

static class Woman extends Human {
}

// 执行代码
public static void main(String[] args) {

Human man = new Man();
// 变量man的静态类型 = 引用类型 = Human:不会被改变、在编译器可知
// 变量man的动态类型 = 实例对象类型 = Man:会变化、在运行期才可知

}
}

变量的静态类型 = 引用类型 :不会被改变、在编译器可知

变量的动态类型 = 实例对象类型 :会变化、在运行期才可知

2、静态分派

定义:

  1. 根据变量的静态类型进行方法分派的行为即根据 变量的静态类型确定执行哪个方法
  2. 发生在编译期,所以不由 Java 虚拟机来执行

应用场景

方法重载(OverLoad) = 静态分派 = 根据 变量的静态类型确定执行(重载)哪个方法。

什么是重载?

如果我们想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同。这些方法之间的关系,我们称之为重载。重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:

  1. 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
  2. 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
  3. 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。

如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class Test { 

// 类定义
static abstract class Human {
}

// 继承自抽象类Human
static class Man extends Human {
}

static class Woman extends Human {
}

// 可供重载的方法
public void sayHello(Human guy) {
System.out.println("hello,guy!");
}

public void sayHello(Man guy) {
System.out.println("hello gentleman!");
}

public void sayHello(Woman guy) {
System.out.println("hello lady!");
}

// 测试代码
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
Test test = new Test();

test.sayHello(man); //man修饰的是Human
test.sayHello(woman); //woman修饰的是Human
}
}

// 运行结果
hello,guy!
hello,guy!

特别注意

a. 变量的静态类型发生变化的情况

可通过强制类型转换改变变量的静态类型

1
2
3
4
5
6
Human man = new Man(); 
test.sayHello((Man)man);
// 强制类型转换
// 此时man的静态类型从 Human 变为 Man

// 所以会调用sayHello()中参数为Man guy的方法,即sayHello(Man guy)

b. 静态分派的优先级匹配问题

问题描述:

背景:现需要进行静态分派

问题:程序中没有显示指 静态类型

解决方案:程序会根据静态类型的优先级从而选择优先的静态类型进行方法分配。

特别注意

  • 上面讲解的主要是基本数据类型的优先级匹配问题
  • 若是引用类型,则根据继承关系进行优先级匹配

注意只跟其编译时类型(即静态类型)相关

优先级顺序为:

  • char > int > long > float > double > Character > Serializable > Object >…
  • 其中…为变长参数,将其视为一个数组元素。变长参数的重载优先级最低。 因为 char 转型到 byte 或 short 的过程是不安全的 所以不会选择参数类型为byte
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//比如:
public class Overload {

private static void sayHello(char arg){
System.out.println("hello char");
}

private static void sayHello(Object arg){
System.out.println("hello Object");
}

private static void sayHello(int arg){
System.out.println("hello int");
}

private static void sayHello(long arg){
System.out.println("hello long");
}

// 测试代码
public static void main(String[] args) {

sayHello('a');
}

}

// 运行结果
hello char

3、动态分派

3.1 定义

根据 变量的动态类型进行方法分派的行为

即根据 变量的动态类型确定执行哪个方法

3.2 应用场景

方法重写(Override)

如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java 虚拟机才会判定为重写。

对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成桥接方法 来实现 Java 中的重写语义。

由于对重载方法的区分在编译阶段已经完成,我们可以认为 Java 虚拟机不存在重载这一概念。

确切地说,Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 定义类
class Human {
public void sayHello(){
System.out.println("Human say hello");

}
}

// 继承自 抽象类Human 并 重写sayHello()
class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");

}
}

class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");

}
}

// 测试代码
public static void main(String[] args) {

// 情况1
Human man = new man();
man.sayHello();

// 情况2
man = new Woman();
man.sayHello();
}
}

// 运行结果
man say hello
woman say hello

// 原因解析
// 1. 方法重写(Override) = 动态分派 = 根据 变量的动态类型 确定执行(重写)哪个方法
// 2. 对于情况1:根据变量(Man)的动态类型(man)确定调用man中的重写方法sayHello()
// 3. 对于情况2:根据变量(Man)的动态类型(woman)确定调用woman中的重写方法sayHello()

invokevirtual指令执行的第一步 = 确定接受者的实际类型

invokevirtual指令执行的第二步 = 将 常量池中 类方法符号引用 解析到不同的直接引用上

第二步即方法重写(Override)的本质

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
例子:静态+动态
A ab = new B(); 这里ab的引用类型是A,但是它指向的内存是类型为B的一个实例。
ab.show(b)执行的时候:
1、首先编译期调用的方法都必须在 class A里面有的才行(因为你的引用类型为A)这里 class A有show(A obj) show(D obj)着两个方法。选择了show(A obj) (因为A是B的父类,向上转型)。这边是静态。
2、运行期ab.show(b)执行show(A obj)的适合,发现ab内存地址指向一个类型为B内存空间,如果class B Override 了 class A的show(A obj)方法,则调用B的方法而不是直接使用show(B obj),这边是动态。

class A {
public String show(D obj){
return ("A and D");
}
public String show(A obj){
return ("A and A");
}
}
class B extends A{
public String show(A obj){
return ("B and A");
}
public String show(B obj){
return ("B and B");
}
}

public class MultiTest{
public static void main(String[] args){
A ab = new B();
B b = new B();
C c = new C();
//ab的show方法先进去,找到参数为A obj,然后再看子类有没有重写 ,重写就执行子类的show
System.out.println(ab.show(b));
System.out.println(ab.show(c));
}
}

运行代码结果如下:
B and A
B and A
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
例子:动态+初始化编译

class Father{
int age = 40;
Father(){
show();
}
public void show () {
System.out.println(age);
}
}
class Son extends Father {
int age = 20;
public void show() {
System.out.println(age);
}
}
public class Demo {
public static void main(String[] args) {
Father f = new Son();
f.show();
}
}

//0 20
//先执行构造函数,父类构造函数执行show的时候
//父类的Age还没有初始化,默认为0,
//f.show的时候,先执行父类,但是子类重写过,所有就执行子类的show方法

public class Demo {
class Super{
int flag=1;
Super(){
test();
} void test(){
System.out.println("Super.test() flag="+flag);
}
}
class Sub extends Super{
Sub(int i){
flag=i;
System.out.println("Sub.Sub()flag="+flag);
} void test(){
System.out.println("Sub.test()flag="+flag);
}
}
public static void main(String[] args) {
new Demo().new Sub(5);
}
}
// Sub.Sub()flag= 1 Sub.Sub()flag= 5
//这个比上面那个区别是:这个子类没有申明flag,直接用父类的,上面的从新申明了

4、单分派与多分派

  • 方法的接收者、方法的参数都可以称为方法的宗量。根据分批基于多少种宗量,可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择的,多分派是根据多于一个的宗量对目标方法进行选择的。

  • Java在进行静态分派时,选择目标方法要依据两点:一是变量的静态类型是哪个类型,二是方法参数是什么类型。因为要根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

  • 运行时阶段的动态分派过程,由于编译器已经确定了目标方法的签名(包括方法参数),运行时虚拟机只需要确定方法的接收者的实际类型,就可以分派。因为是根据一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

5、虚拟机动态分派的实现

  • 由于动态分派是非常频繁的动作,而动态分派在方法版本选择过程中又需要在方法元数据中搜索合适的目标方法,虚拟机实现出于性能的考虑,通常不直接进行如此频繁的搜索,而是采用优化方法。

  • 其中一种“稳定优化”手段是:在类的方法区中建立一个虚方法表(Virtual Method Table, 也称vtable, 与此对应,也存在接口方法表——Interface Method Table,也称itable)。使用虚方法表索引来代替元数据查找以提高性能。其原理与C++的虚函数表类似。

  • 通过虚方法表存放各个方法的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口;如果子类中重写了这个方法,子类方法表中的地址将会诶替换为指向子类实现版本的入口地址。

8、类加载器与双亲委派模型

发表于 2019-07-10 | 更新于 2019-07-08 | 分类于 看书笔记 , 深入理解Java虚拟机

显示加载

  • 当 JVM 执行第一行代码“ Student s = new Student();”时
  • JVM 先碰到了 Student 类,“ Student s = new Student();”
  • 此时,JVM 将查看方法区中是否有 Student 对应的 Class 对象(我们学习过反射,都知道 Class 对象,在同一个 JVM 中,可以有很多的 Student 实例,但是 Student 的 Class 对象只有一个)。
  • 如果加载过了,那么直接返回
  • 因为是第一次执行,方法区中没有 Student 的 Class 对象,此时 JVM 就会调用类加载器(ClassLoader)。
  • ClassLoader会使用父类加载器,父类加载器如果加载过了直接返回,没有就调用父类的父类。直到没有父类加载器,他会判断自己无法完成该加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会自己去加载。

1.作用

  1. 实现类加载的功能
  2. 确定被加载类在Java虚拟机中的唯一性

1.1 实现类加载的功能

即实现类加载过程中“加载”环节里“通过类的全限定名来获取定义此类的二进制字节流”的功能(类加载5个阶段)

1.2 确立被加载类在Java虚拟机中的唯一性

  • 确定两个类是否相等的依据:是否由同一个类加载器加载
  1. 若由同一个类加载器加载,则这两个类相等; 若由不同的类加载器加载,则这两个类不相等。
  2. 即使两个类来源于同一个 Class 文件、被同一个虚拟机加载,这两个类都不相等
  • 在实际使用中,是通过下面方法的返回结果(Boolean值)进行判断:
  1. Class对象的equals()方法
  2. Class对象的isAssignableFrom()方法
  3. Class对象的isInstance()方法
  • 当然也会使用instanceof关键字做对象所属关系判定等情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/例子
public class Test {

// 自定义一个类加载器:myLoader
// 作用:可加载与自己在同一路径下的Class文件
static ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {

if (!name.equals("com.carson.Test"))
return super.loadClass(name);

try {
String fileName = name.substring(name.lastIndexOf(".") + 1)
+ ".class";

InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(fileName);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);

} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};

public static void main(String[] args) throws Exception {

Object obj = myLoader.loadClass("com.carson.Test");
// 1. 使用该自定义类加载器加载一个名为com.carson.Test的类
// 2. 实例化该对象

System.out.println(obj);
// 输出该对象的类 ->>第一行结果分析

System.out.println(obj instanceof com.carson.Test);
// 判断该对象是否属于com.carson.Test类 ->>第二行结果分析

}

}

<-- 输出结果 -->
class com.carson.Test
false

// 第一行结果分析
// obj对象确实是com.carson.Test类实例化出来的对象

// 第二行结果分析
// obj对象与类com.huachao.Test做所属类型检查时却返回了false
// 原因:虚拟机中存在了两个Test类(1 & 2):1是由系统应用程序类加载器加载的,2是由我们自定义的类加载器加载
// 虽然都是来自同一个class文件,但由于由不同类加载器加载,所以依然是两个独立的类
// 做对象所属类型检查结果自然为false。

重点为:

  1. 启动类加载器
  2. 扩展类加载器
  3. 应用程序类加载器

2.1启动类加载器(Bootstrap ClassLoader)

  • 作用:负责加载以下类
  1. 存放在 < JAVA_HOME > \ lib中目录中的类
  2. 被-Xbootclasspath参数所指定路径中,并且是被虚拟机识别的类库

仅按文件名识别,如:rt.jar中,名字不符合的类库即使放在LIB目录中也不会被加载

  • 特别注意
  1. 启动类加载器无法被Java程序直接引用
  2. 用户在编写自定义类加载器时,若需把加载请求委派给引导类加载器,直接使用null代替即可,如java.lang.ClassLoader.getClassLoader()方法所示:
1
2
3
4
5
6
7
8
9
10
11
@CallerSensitive 
public ClassLoader getClassLoader() {
ClassLoader cl = getClassLoader0();
if (cl == null)
return null;
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
ClassLoader.checkClassLoaderPermission(cl, Reflection.getCallerClass());
}
return cl;
}

2.2扩展类加载器(Extension ClassLoader)

  • 作用:负责加载以下类:
  1. < JAVA_HOME > \ lib中\分机目录中的类库
  2. 被java.ext.dirs系统变量所指定的路径中的所有类库
  • 特别注意
  1. 由sum.misc.Launcher $的ExtClassLoader类实现
  2. 开发者可以直接使用扩展类加载器

2.3应用程序类加载器(Application ClassLoader)

  • 作用:

负责加载用户类路径(ClassPath)上所指定的类库

  • 特别注意
  1. 也称为系统类加载器,因为该类加载器是类加载器中的getSystemClassLoader()方法的返回值
  2. 由sum.misc.Launcher $ AppClassLoader类实现
  3. 开发者可以直接使用该类加载器
  4. 若开发者没自定义类加载器,程序默认使用该类加载器

各种类加载器的使用并不是孤立的,而是相互配合使用

在Java虚拟机中,各种类加载器配合使用的模型(关系)是双亲委派模型

3.双亲委派模型

作用:

  • 因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子类加载器再加载一次。避免同样多份字节码加载
  • 安全
  • 采用双亲委派的一个好处是比如加载位于rt.jar的包中的类java.lang.Object中,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个目标对象。

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。因此,使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处:类随着它的类加载器一起具备了一种带有优先级的层次关系。

双亲委派模型的工作流程代码实现在 java.lang.ClassLoader中的的loadClass()中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//具体如下
@Override
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
Class<?> c = findLoadedClass(name);

// 检查需要加载的类是否已经被加载过
if (c == null) {
try {
// 若没有加载,则调用父加载器的loadClass()方法
if (parent != null) {
c = parent.loadClass(name, false);
}else{
// 若父类加载器为空,则默认使用启动类加载器作为父加载器
c=findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 若父类加载器加载失败会抛出ClassNotFoundException,
//说明父类加载器无法完成加载请求
}
if(c==null){
// 在父类加载器无法加载时
// 再调用本身的findClass方法进行类加载
c=findClass(name);
}
}
if(resolve){
resolveClass(c);
}
return c;
}

各个类加载器之间是组合关系,并非继承关系。

步骤总结:若一个类加载器收到了类加载请求

  1. 该类把加载请求委派给父类加载器去完成,而不会自己去加载该类:每层的类加载器都是如此,因此所有的加载请求最终都应传送到顶层的启动类加载器中

  2. 只有当父类加载器反馈自己无法完成该加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会自己去加载

以自定义STRIG类为例子

加载某个类时,优先使用父类加载器加载需要使用的类。如果我们自定义了java.lang.String这个类,加载该自定义的字符串类,该自定义字符串类使用的加载器是AppClassLoader ExtClassLoader,所有这些加载字符串使用的类加载器是ExtClassLoader,但是类加载器ExtClassLoader在jre / lib / ext目录下没有找到String.class类。然后使用ExtClassLoader父类的加载器BootStrap,父类加载器BootStrap在JRE / lib目录的rt.jar找到了String.class,将其加载到内存中。这就是类加载器的委托机制。

优点
Java的类随着它的类加载器一起具备了一种带优先级的层次关系

如:类java.lang.Object(存放在rt.jar中)在加载过程中,无论哪一个类加载器要加载这个类,最终需委派给模型顶端的启动类加载器进行加载,因此对象类在程序的各种类加载器环境中都是同一个类。

若没有使用双亲委派模型(即由各个类加载器自行去加载),用户编写了一个java.lang.Object继承的类(放在类路径中),那系统中将出现多个不同的对象类,Java的体系中最基础的行为就无法保证。

4.自定义类加载器

以下代码中的FileSystemClassLoader是自定义类加载器,继承自java.lang.ClassLoader,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class文件),然后读取该文件内容,最后通过defineClass()方法来把这些字节代码转换成java.lang.Class类的实例。

java.lang.ClassLoader的loadClass()实现了双亲委派模型的逻辑,自定义类加载器一般不去重写它,但是需要重写findClass()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class FileSystemClassLoader extends ClassLoader {

private String rootDir;

public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}

protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}

private byte[] getClassData(String className) {
String path = classNameToPath(className);
try {
InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

private String classNameToPath(String className) {
return rootDir + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//主要是通过继承自ClassLoader类 从而自定义一个类加载器 MyClassLoader.java
// 继承自ClassLoader类
public class MyClassLoader extends ClassLoader {
// 类加载器的名称
private String name;
// 类存放的路径
private String classpath = "E:/";

MyClassLoader(String name) {
this.name = name;
}

MyClassLoader(ClassLoader parent, String name) {
super(parent);
this.name = name;
}

@Override
public Class<?> findClass(String name) {
byte[] data = loadClassData(name);
return this.defineClass(name, data, 0, data.length);
}

public byte[] loadClassData(String name) {
try {
name = name.replace(".", "//");
System.out.println(name);
FileInputStream is = new FileInputStream(new File(classpath + name
+ ".class"));
byte[] data = new byte[is.available()];
is.read(data);
is.close();
return data;

} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}

假如我们的类不在类路径下,而我们又想读取一个自定义的目录下的类,如果做呢?
//示例读取c:/test/com/test.jdk/Key.class这个类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.test.jdk;

public class Key {
private String key = "111111";
}
import org.apache.commons.io.IOUtils;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

public class LocalClassLoader extends ClassLoader {

private String path = "c:/test/";

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class<?> cls = findLoadedClass(name);
if (cls != null) {
return cls;
}

if (!name.endsWith(".Key")) {
return super.loadClass(name);
}

try {
InputStream is = new FileInputStream(path + name.replace(".", "/") + ".class");
byte[] bytes = IOUtils.toByteArray(is);
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
e.printStackTrace();
}

return super.loadClass(name);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
//自定义类加载器正常加载到类,程序最后输出:111111
public static void main(String[] args) {
try {
LocalClassLoader lcl = new LocalClassLoader();
Class<?> cls = lcl.loadClass("com.test.jdk.Key");
Field field = FieldUtils.getField(cls, "key", true);
Object value = field.get(cls.newInstance());
System.out.println(value);
} catch (Exception e) {
e.printStackTrace();
}
}

URLClassLoader上面自定义一个类加载器来读取自定义的目录,其实可以直接使用URLClassLoader就能读取,它已经实现了路径下类的读取逻辑

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
URLClassLoader ucl = new URLClassLoader(new URL[]{new URL("c:/test/")});
Class<?> cls = ucl.loadClass("com.test.jdk.Key");
Field field = FieldUtils.getField(cls, "key", true);
Object value = field.get(cls.newInstance());
System.out.println(value);
} catch (Exception e) {
e.printStackTrace();
}
}

5.破坏双亲委派模型

双亲委派模型并不是一个强制性的约束模型。

双亲委派模型主要出现过3次较大规模“被破坏”的情况。

  1. 第一次破坏是因为类加载器和抽象类java.lang.ClassLoader中在JDK1.0就存在的,而双亲委派模型在JDK1.2之后才被引入,为了兼容已经存在的用户自定义类加载器,引入双亲委派模型时做了一定的妥协:在java.lang.ClassLoader中中引入了一个的findClass()方法,在此之前,用户去继承java.lang.ClassLoader中的唯一目的就是重写的loadClass()方法.JDK1 0.2之后不提倡用户去覆盖的loadClass()方法,而是把自己的类加载逻辑写到的findClass()方法中,如果的loadClass()方法中如果父类加载失败,则会调用自己的的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型规则的。

  2. 第二次破坏是因为模型自身的缺陷,现实中存在这样的场景:基础的类加载器需要求调用用户的代码,而基础的类加载器可能不认识用户的代码为此,Java的设计团队引入的设计时“线程上下文类加载器(Thread Context ClassLoader)”。这样可以通过父类加载器请求子类加载器去完成类加载动作。已经违背了双亲委派模型的一般性原则。

  3. 第三次破坏是由于用户对程序动态性的追求导致的。这里所述的动态性是指:“代码热替换”,“模块热部署”等等比较热门的词。说白了就是希望应用程序能够像我们的计算机外设一样,接上鼠标,U盘不用重启机器就能立即使用.OSGi是当前业界“事实上”的Java的模块化标准,OSGi的实现模块化热部署的关键是它自定义的类加载器机制的实现。每一个程序模块(OSGi的中称为包)都有一个自己的类加载器,当需要更换一个包时,就把束连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。当收到类加载请求时,osgi将按照下面的顺序进行类搜索:

  • 1)将以java。*开头的类委派给父类加载器加载
  • 2)否则,将委员列表名单(配置文件org.osgi.framework.bootdelegation中定义)内的类委派给父类加载器加载
  • 3)否则,检查是否在Import-Package中声明,如果是,则委派给出口这个类的Bundle的类加载器加载
  • 4)否则,检查是否在Require-Bundle中声明,如果是,则将类加载请求委托给必需的捆绑的类加载器
  • 5)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载
  • 6)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载
  • 7)否则,查找Dynamic Import-Package(动态导入只有在真正用到此包的时候才进行加载)的Bundle,委派给对应Bundle的类加载器加载
  • 8)否则,类查找失败

隐式加载

他们都能在运行时对任意一个类,都能够知道该类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性。

  • 的loadClass

加载

链接(校验准备解析)

初始化

Classloder.loaderClass得到的类是还没有链接的

  • 的forName

的Class.forName得到的类是已经初始化完成的

调用的时候静代码块直接运行

这样我们需要初始化后才能得到的DriverManager,所以我们选择使用的Class.forName()

7、类加载的5个过程

发表于 2019-07-10 | 更新于 2019-07-05 | 分类于 看书笔记 , 深入理解Java虚拟机

1、类加载的本质

将描述类的数据从Class文件加载到内存&对数据进行校验、转换解 和初始化,最终形成可被虚拟机直接使用的Java使用类型:Class文件是一串二进制字节流。

2、类加载过程

分为七个步骤,其中五个关键步骤:加载 -> 验证 -> 准备 -> 解析 -> 初始化

这几个阶段中的:加载、验证、准备、初始化、卸载的顺序是固定的。但是解析则有可能在初始化之后才开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。

注意:上文所说按部就班地“开始”,而不是“进行”或“完成”,强调这点是因为这些阶段通常都是互相交叉地混合式进行,通常会在一个阶段执行的过程中调用、激活另一个阶段。加载阶段 与 连接阶段 的部分内容(如一部分字节码文件格式验证动作)是交叉进行的。加载阶段尚未完成,连接阶段可能已经开始,但这些加载加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

  • 虚拟机对于类的初始化阶段严格规定了有且仅有只有5种情况如果对类没有进行过初始化,则必须对类进行“初始化”!
  1. 遇到new、读取一个类的静态字段(getstatic)、设置一个类的静态字段(putstatic)、调用一个类的静态方法(invokestatic)。类没有初始化,那么需要先触发进行初始化。

  2. 使用java.lang.reflect包的方法对类进行反射调用时。

  3. 当类初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。(如果是接口,则不必触发其父类初始化)

  4. 当虚拟机执行一个main方法时,会首先初始化main所在的这个主类。

  5. 当只用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。(暂未研究此种场景)

  • 不会被初始化的三种情况:
  1. 对于静态字段,只有直接定义这个字段的类才会被初始化,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
  2. 所有引用类的方式都不会触发初始化称为被动引用。比如数组 A[] a = new A[];
  3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接饮用到定义常量的类。进一步解释,虽然在main方法中引用了ConstClass类中的常量HELLO,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello”存储到了Main类的常量池中,之后对ConstClass.HELLO的引用实际上都被转化为Main类对自身常量池的引用。也就是说,两个类在编译过后实际上不存在任何联系了。

步骤1:加载(加载到方法区)

加载,将外部的类文件加载到虚拟机或者存储到方法区内是指查找字节流,并且据此创建类的过程。前面提到,对于数组类来说,它并没有对应的字节流,而是由Java虚拟机直接生成的。对于其他的类来说,Java虚拟机则需要借助类加载器来完成查找字节流的过程。类加载器实现的功能是即为加载阶段获取二进制字节流的时候。

  • 虚拟机完成三件事情:
  1. 通过一个类的全限定名来获取此类的二进制字节流;
    注意:这里的二进制字节流并不只是单纯地从Class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其他文件生成(JSP应用)等。

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
    注:“方法区域Java堆一样,是各线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据”。而方法区中的数据存储结构格式虚拟机自行定义。

  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
    注:加载阶段完成后,虚拟机在内存中实例化一个java.lang.Class类的对象(Class是一个实实在在的对象,是记录着类成员、接口等信息的对象)。还有一点是,我们都知道对象肯定是存放在堆中的,但Class对象比较特殊,对于HotSpot虚拟机而言,Class对象是存放在方法区中的。

  • 对于非数组类

非数组类加载过程是开发人员可控性最强的,可以使用系统提供的引导类加载器,也可以由用户自定义类加载器完成(重新一个类加载器 loadClass())。 对于数组类而言,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的,但是数组类的元素类型(Element Type,是指数组去掉所有维度的类型)最终要靠类加载器去创建,一个数组类(简称为C)创建过程要遵循以下规则:

  1. 如果数组的组件类型是引用类型(非基础类型),那就递归去加载这个组件类型数组类将在加载该组件类型的类加载器的类名称空间上被标识。
  2. 如果数组组件类型不是引用类型(例如int[]数组),Java虚拟机将会把该数组标记为与引导类加载器关联。
  3. 数组类的可见性与他的组件类型可见性一致,如果组件类型不是引用类型,那数组的可见性将默认为public。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存实例化一个 java.lang.Class类的对象(并无明确规定是在Java 堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽是对象,但存放在方法区里),这个对象将作为程序访问方法区中的这些类型数据的外部接口。

步骤2:验证

验证是连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段非常重要,直接决定了虚拟机是否能承受恶意代码的攻击,从执行性能的角度来讲,该阶段的工作量在虚拟机的类加载子系统中占有了相当大一部分。

Java语言本身是相对安全的语言(相对于C/C++),使用纯粹的Java代码无法做到诸如访问数组边界之外的数据、将一个对象转型为它未实现的数据类型、跳转到不存在的代码行等,如果这样做了,编译器将拒绝编译,但是Class文件不一定由Java源码编译而来,完全可以使用任何途径,如:用十六进制编辑器直接编写来产生Class文件,在字节码层面上,上述Java代码无法做到的事情都是可以实现的,此时虚拟机如果不检查输入的字节流,很有可能因为载入了有害的字节流而导致系统崩溃,所以验证时虚拟机对自身保护的一项重要工作。

验证阶段是非常重要的,但不是一定必要的阶段(对程序运行期没有影响),如果运行的全部代码都被反复使用和验证过,那么在实施阶段可以考虑通过参数-Xverify:none 来关闭类验证措施,以缩短虚拟机类加载的时间。

大致都会完成以下四个阶段的验证:

  1. 文件格式的验证
  2. 元数据的验证
  3. 字节码验证
  4. 符号引用验证

步骤3:准备

  • 正式为类变量(静态变量)分配内存并设置变量初始值的阶段这些变量所使用的内存都在方法区中进行分配,对静态字段的具体初始化,则会在稍后的初始化阶段中进行。
  • 除了分配内存外,部分 Java 虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。
  • 在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。

此阶段有2个容易混淆的部分需要特别强调:

该阶段进行内存分配的仅包括类变量(被static修饰的变量),不包括实例变量,实例变量将在对象初始化时随对象一起分配在堆内存中 这里所说的初始值“通常情况下”是指数据类型的零值 假设一个类变量的定义为:

1
public static int value = 123;

那变量value在准备阶段过后的初始化值为0而不是123,因为这是尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后存放在类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。

上面提到的在“通常情况”下初始值为零值,但还是会有一些特殊情况,如下:

类字段的字段属性表中存在ConstantValue属性(只有同时被final和static修饰的字段才有ConstantValue属性),那在准备阶段变量value就会被初始化微ConstantValue属性所指定的值。编译时Javac将会为vaue生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

1
public final static int value = 123;

步骤4:解析

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

  • 符号引用 (Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。 符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须一致,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

  • 直接引用(Direct Refenrences):直接引用可以是直接目标的指针、相对偏移量或是一个能间接定位到目标的句柄。 直接引用是和虚拟机实现的内存布局有关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那么引用的目标必定已经在内存中存在


对同一个符号引用进行多次解析请求是很常见的事情,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。但对于invokedynamic指令,上面规则则不成立。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对其他invokedynamic指令也同样生效。因为invokedynamic指令是JDK1.7新加入的指令,目的用于动态语言支持,它所对应的引用称为“动态调用点限定符”(Dynamic Call Site Specifier),这里“动态”的含义就是必须等到程序实际运行到这条指令的时候,解析动作才能进行。相对的,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有执行代码时就进行解析。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号进行引用,下面只对前4种引用的解析过程进行介绍,对于后面3种与JDK1.7新增的动态语言支持息息相关。

  1. 类或接口解析:假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成这个解析的过程需要以下3个步骤
  • C不是一个数组类型,虚拟机会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,有可能触发其他相关类的加载动作。
  • 如果C是一个数组类型,并且数组的元素类型为对象,例如N是“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型,如果N的描述符是前面那样,需要加载的元素类型就是“java.lang,Integer”,接着由虚拟机生一个代表此数组维度和元素的数组对象。
  • 如果上面的步骤没有出现异常,C已经在虚拟机中成为了一个有效的类和接口了,但是解析完成之前还有进行符号引用验证,确保D是否具备对C的访问权限。
  1. 字段解析:要解析字段符号引用,首先要对字段表内字段所属的类或接口的符号引用进行解析,如果解析成功,那这个字段所属的类或接口用C表示,虚拟机规范要求安好如下步骤对C进行后续字段的搜索
  • 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
  • 否则如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
  • 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果父类包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束
  • 否则查找失败,抛出异常java.lang.NoSuchMethodError
  • 如果上面的步骤没有出现异常,但是解析完成之前还有进行符号引用验证,确保是否具备对字段的访问权限。
  1. 类方法解析:要解析类方法符号引用,首先要对类方法表中方法所属的类或接口的符号引用进行解析,如果解析成功,那这个方法所属的类或接口用C表示,虚拟机规范要求安好如下步骤对C进行后续类方法的搜索
  • 类方法和接口房符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,直接抛出java.lang.IncompatibleClassChangeError异常
  • 如果C本身就包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束
  • 否则,在C的父类中递归查找,如果父类包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束
  • 在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法, 如果存在匹配的方法,说明类C是一个抽象类,这时查找结束,抛出java.lang,AbstractMethodError异常 这个需要这么理解,如果是普通的类去实现某一个接口的方法的话,那么它肯定在第(2)步已经直接返回.如果能执行到第(4)步,则说明C本身的常量池中并没有对应的直接引用.那么只能是说明这个方法是抽象方法.包含抽象方法的类必定是抽象类,所以这里有个结论就是C是抽象类.
  • 否则查找失败,抛出异常java.lang.NoSuchMethodError
  • 如果上面的步骤没有出现异常,但是解析完成之前还有进行符号引用验证,确保是否具备对方法的访问权限。
  1. 接口方法解析:要解析接口方法符号引用,首先要对接口方法表中方法所属的类或接口的符号引用进行解析,如果解析成功,那这个方法所属接口用C表示,虚拟机规范要求安好如下步骤对C进行后续类方法的搜索
  • 类方法和接口房符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个类而不是接口,直接抛出java.lang.IncompatibleClassChangeError异常
  • 如果C本身就包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束
  • 否则,在C的父接口中递归查找,直到找到java.lang.Object类位置。如果包含了简单名称和描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束
  • 否则查找失败,抛出异常java.lang.NoSuchMethodError 接口中方法默认都是public的,因此不存在访问权限的事

符号引用就是一个类中(当然不仅是类,还包括类的其他部分,比如方法,字段等),引入了其他的类,可是JVM并不知道引入的其他类在哪里,所以就用唯一符号来代替,等到类加载器去解析的时候,就把符号引用找到那个引用类的地址,这个地址也就是直接引用。

步骤5:初始化

将一个类中所有被static关键字标识的代码统一执行一遍,如果执行的是静态变量,那么就会使用用户指定的值覆盖之前在准备阶段设置的初始值;如果执行的是static代码块,那么在初始化阶段,JVM就会执行static代码块中定义的所有操作。

在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。 如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。 除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >。 类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。

  • ()方法是有编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是有语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问。
  • ()方法与类的构造函数(或者说实例构造器()方法)不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕;
  • 由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作;
  • ()方法对于类和接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法;
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成()方法。但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的()方法。
  • 虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。如果一个类的()方法中有耗时很长的操作,就可能造成多个进程阻塞。

那么,类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况:

  1. 当虚拟机启动时,初始化用户指定的主类;
  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
  5. 子类的初始化会触发父类的初始化;
  6. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  7. 使用反射 API 对某个类进行反射调用时,初始化这个类;
  8. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Singleton{
private static Singleton singleton = new Singleton();
public static int value1;
public static int value2 = 0;

private Singleton(){
value1++;
value2++;
}

public static Singleton getInstance(){
return singleton;
}

}

class Singleton2{
public static int value1;
public static int value2 = 0;
private static Singleton2 singleton2 = new Singleton2();

private Singleton2(){
value1++;
value2++;
}

public static Singleton2 getInstance2(){
return singleton2;
}

}

public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
System.out.println("Singleton1 value1:" + singleton.value1);
System.out.println("Singleton1 value2:" + singleton.value2);

Singleton2 singleton2 = Singleton2.getInstance2();
System.out.println("Singleton2 value1:" + singleton2.value1);
System.out.println("Singleton2 value2:" + singleton2.value2);
}

//说出运行的结果:
Singleton1 value1 : 1
Singleton1 value2 : 0
Singleton2 value1 : 1
Singleton2 value2 : 1

分析

Singleton输出结果:1 0
原因:

  1. 首先执行main中的Singleton singleton = Singleton.getInstance();
  2. 类的加载:加载类Singleton
  3. 类的验证
  4. 类的准备:为静态变量分配内存,设置默认值。这里为singleton(引用类型)设置为null,value1,value2(基本数据类型)设置默认值0
  5. 类的初始化(按照赋值语句进行修改):

执行private static Singleton singleton = new Singleton();

先执行Singleton的构造器:

value1++;

value2++;

此时value1,value2均等于1

再执行

public static int value1;

public static int value2 = 0;

此时value1=1,value2=0

Singleton2输出结果:1 1

原因:

  1. 首先执行main中的Singleton2 singleton2 = Singleton2.getInstance2();
  2. 类的加载:加载类Singleton2
  3. 类的验证
  4. 类的准备:为静态变量分配内存,设置默认值。这里为value1,value2(基本数据类型)设置默认值0,singleton2(引用类型)设置为null,
  5. 类的初始化(按照赋值语句进行修改):

执行

public static int value2 = 0;

此时value2=0(value1不变,依然是0);

执行

private static Singleton singleton = new Singleton();

执行Singleton2的构造器:value1++;value2++;

此时value1,value2均等于1,即为最后结果

6、类文件结构

发表于 2019-07-10 | 更新于 2019-07-03 | 分类于 看书笔记 , 深入理解Java虚拟机

1、概述

计算机只认识0和1,我们编写的程序需要经编译器翻译为由0和1构成的二进制文件才能被计算机执行。伴随着虚拟机和大量建立在虚拟机上程序语言的出现,将程序编译为本地字节码文件已不再是唯一的选择,越来越多的程序语言选择了与操作系统无关的,平台中立的格式作为程序编译后的存储格式。 各个不同平台的虚拟机与所有平台都统一使用相同的程序存储格式——字节码,它是构成平台无关性的基石。

在Java中,JVM可以理解的代码就叫做字节码(即扩展名为.class的文件),它不面向任何特定的处理器,只面向虚拟机。Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以Java程序运行时比较高效,而且,由于字节码并不针对一种特定的机器,因此,Java程序无须重新编译便可在多种不同操作系统的计算机上运行。

Clojure(Lisp 语言的一种方言)、Groovy、Scala 等语言都是运行在 Java 虚拟机之上。下图展示了不同的语言被不同的编译器编异常.class文件最终运行在 Java 虚拟机之上。.class文件的二进制格式可以使用 WinHex 查看。

可以说.class文件是不同的语言在 Java 虚拟机之间的重要桥梁,同时也是支持 Java 跨平台很重要的一个原因。

2、Class文件结构总结

注意:任何一个class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。

class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在class文件之中,中间没有任何分隔符,使得整个class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在,当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。

  • Big-Endian:高位在前存储方式,高位字节放在内存的低地址端,低位字节放在内存的高地址端
  • Little-Endian:低位在前存储方式,高位字节放在内存的高地址端,低位字节放在内存的低地址端

Class文件格式采用一种类似C语言结构体的伪结构来存储数据,伪结构有两种类型:无符号数和表。

  • 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成的字符串值。
  • 表是由多个无符号数或者其它表作为数据项构成的复合数据类型,所有表都习惯性地以”_info”结尾。表用于描述有层次关系的复合结构的数据,整个class文件本质上就是一张表。

根据 Java 虚拟机规范,类文件由单个 ClassFile 结构组成: 无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的格式,这时称这一系列连续的某一类型的数据为某一类型的集合。

下面详细介绍一下 Class 文件结构涉及到的一些组件。

Class文件字节码结构组织示意图

2.1 魔数

1
u4             magic; //Class 文件的标志

每个 Class 文件的头四个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件。

程序设计者很多时候都喜欢用一些特殊的数字表示固定的文件类型或者其它特殊的含义。

2.2 Class 文件版本

1
2
u2             minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号

紧接着魔数的四个字节存储的是 Class 文件的版本号:第五和第六是次版本号,第七和第八是主版本号。

高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致。

2.3 常量池

1
2
u2             minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号

紧接着魔数的四个字节存储的是 Class 文件的版本号:第五和第六是次版本号,第七和第八是主版本号。

高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致。

2.3 常量池

1
2
u2             constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池

紧接着主次版本号之后的是常量池,常量池的数量是constant_pool_count-1(常量池计数器是从1开始计数的,将第0项常量空出来是有特殊考虑的,索引值为0代表“不引用任何一个常量池项”)。

常量池主要存放两大常量:字面量和符号引用。字面量比较接近于 Java 语言层面的的常量概念,如文本字符串、声明为 final 的常量值等。而符号引用则属于编译原理方面的概念。包括下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

常量池中每一项常量都是一个表,这14种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型.

类型 标志(tag) 描述
CONSTANT_utf8_info 1 UTF-8编码的字符串
CONSTANT_Integer_info 3 整形字面量
CONSTANT_Float_info 4 浮点型字面量
CONSTANT_Long_info 5 长整型字面量
CONSTANT_Double_info 6 双精度浮点型字面量
CONSTANT_Class_info 7 类或接口的符号引用
CONSTANT_String_info 8 字符串类型字面量
CONSTANT_Fieldref_info 9 字段的符号引用
CONSTANT_Methodref_info 10 类中方法的符号引用
CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
CONSTANT_NameAndType_info 12 字段或方法的符号引用
CONSTANT_MothodType_info 16 标志方法类型
CONSTANT_MethodHandle_info 15 表示方法句柄
CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点

.class 文件可以通过javap -v class类名 指令来看一下其常量池中的信息(javap -v class类名-> temp.txt :将结果输出到 temp.txt 文件)。

关于怎么查常量项

2.4 访问标志

在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口,是否为public 或者 abstract类型,如果是类的话是否声明为final等等。

类访问和属性修饰符:

我们定义了一个 Employee 类

1
2
3
4
package top.snailclimb.bean;
public class Employee {
...
}

通过javap -v class类名 指令来看一下类的访问标志。

2.5 当前类索引,父类索引与接口索引集合

1
2
3
4
u2             this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口
u2 interfaces[interfaces_count];//一个雷可以实现多个接口

类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。

接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按implents(如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中。

怎么查


2.6 字段表集合

1
2
u2             fields_count;//Class 文件的字段的个数
field_info fields[fields_count];//一个类会可以有个字段

字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。

field info(字段表) 的结构:

  • access_flags: 字段的作用域(public ,private,protected修饰符),是实例变量还是类变量(static修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。
  • name_index: 对常量池的引用,表示的字段的名称;
  • descriptor_index: 对常量池的引用,表示字段和方法的描述符;
  • attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
  • attributes[attributes_count]: 存放具体属性具体内容。
    上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。

字段的 access_flags 的取值:

由Java本身的语言规则决定:ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED中只能选一个;ACC_FINAL、ACC_VOLATILE不能同时选择;接口中字段必须有ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志。

对于数组来说,每多一个维度使用前置一个“[”表示。比如String[][],那么用[[Ljava/lang/String表示。如果方法,则在前面多一个() ,比如void inc(),那么用()V表示。

字段表集合中不会列出从超类或者父类接口中继承而来的字段,但有可能列出原本Java代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。Java语言中不管两个字段的数据类型、修饰符是否相同,都不能使用一样的名称;但对于字节码而言,两个字段的描述符不一致,字段名可以相同。

2.7 方法表集合

1
2
u2             methods_count;//Class 文件的方法的数量
method_info methods[methods_count];//一个类可以有个多个方法

methods_count 表示方法的数量,而 method_info 表示的方法表。

Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。

method_info(方法表的) 结构:

因volatile和transient不能修饰方法,方法表的访问标志中没有ACC_VOLATILE、ACC_TRANSIENT;sysnchronized、native、strictfp和abstract可以修饰方法,因此方法表的访问标志中添加了ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。

方法表的 access_flag 取值:

如果父类方法在子类中没有被重写,方法表集合中就不会出现来自父类的方法信息,可能会出现编译器自动添加的方法,如类构造器””,方法和实例构造器””方法。

Java中重载方法,要与原方法具有相同的简单名称,与原方法不同的特征签名;在对应的class文件中,特征签名就是方法各个参数在常量池中的字段符号引用的集合,但返回值不在其中,因此Java里无法仅仅依靠返回值对一个方法进行重载。但在class文件中,两个方法名称和特征签名相同,返回值不同的方法,可以合法存在与同一个class文件中。

注意:因为volatile修饰符和transient修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized、native、abstract等关键字修饰方法,所以也就多了这些关键字对应的标志。

2.8 属性表集合

1
2
u2             attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合

在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。

5、垃圾收集器

发表于 2019-07-10 | 更新于 2019-07-03 | 分类于 看书笔记 , 深入理解Java虚拟机

垃圾收集器的定义分类等概念

1. Serial收集器

1.1 定义

最基本、发展历史最长的垃圾收集器

1.2 优点

并发收集

在进行垃圾收集时,必须暂停其他所有工作线程(Stop The World),直到收集结束。Stop The World 暂停工作线程 是在用户不可见的情况下进行

并发与并行的区别

  • 并发:在 某一时段内,交替执行多个任务(即先处理A再处理B,循环该过程)
  • 并行:在 某一时刻内,同时执行多个任务(即同时处理A、B)

单线程

只使用 一条线程 完成垃圾收集(GC线程)

效率高

  • 对于限定单CPU环境来说,Serial收集器没有线程交互开销(专一做垃圾收集),拥有更高的单线程收集效率。
  • 垃圾收集高效:即其他工作线程停顿时间短(可控制在100ms内) 只要垃圾收集发生的频率不高,完全可以接受

1.3 使用的垃圾收集算法

复制 算法(新生代)

1.4 应用场景

客户端模式下,虚拟机的 新生代区域

1.5 工作流程

2. Serial Old收集器

2.1 定义

Serial收集器 应用在老年代区域 的版本

2.2 优点

并发、单线程、效率高

同Serial收集器,此处不作过多描述

2.3 使用的垃圾收集算法

标记-整理 算法(老年代)

2.4 应用场景

在客户端模式下,虚拟机的老年代区域

在服务器模式下:

  • 与 Parallel Scavenge 收集器搭配使用
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用

2.5 工作流程

3. ParNew 收集器

3.1 定义

Serial收集器的多线程版本。

3.2 优点

并发收集

在进行垃圾收集时,必须暂停其他所有工作线程(Stop The World),直到收集结束。 暂停工作线程是在用户不可见的情况下进行

多线程收集

  • 使用条垃圾收集线程(GC线程) 完成垃圾收集
  • 由于存在线程交互的开销,所以在单CPU环境下,性能差于 Serial收集器

与CMS收集器配合工作

目前,只有ParNew 收集器能与 CMS收集器 配合工作
由于CMS收集器使用广泛,所以该特点非常重要。
关于CMS收集器 下面会详细说明

3.3 使用的垃圾收集算法

复制算法(新生代)

3.4 应用场景

  • 服务器模式下,虚拟机的 新生代区域
  • 多线程收集

3.5 工作流程

4. Parallel Scavenge收集器

4.1 定义

ParNew 收集器的升级版

4.2 特点

  • 具备ParNew收集器并发、多线程收集的特点
  • 以达到可控制吞吐量为目标

其他收集器的目标是:尽可能缩短垃圾收集时间,

而Parallel Scavenge收集器的目标则是:达到可控制吞吐量

  1. 吞吐量:CPU用于运行用户代码的时间 与 CPU总消耗时间(运行用户代码时间+垃圾收集时间)的比值
  2. 如:虚拟机总共运行100分钟,其中垃圾收集时间=1分钟、运行用户代码时间 = 99分钟,那吞吐量 = 99 / 100 = 99%

自适应

该垃圾收集器能根据当前系统运行情况,动态调整自身参数,从而达到最大吞吐量的目标。

  1. 该特性称为:GC 自适应的调节策略
  2. 这是Parallel Scavenge收集器与 ParNew 收集器 最大的区别

4.3 使用的垃圾收集算法

复制 算法(新生代)

4.4 应用场景

服务器模式下,虚拟机的 新生代区域

4.5 工作流程

5. Parallel Old收集器

5.1 定义

Parallel Scavenge收集器 应用在老年代区域 的版本

5.2 特点

以达到 可控制吞吐量 为目标、自适应调节、多线程收集

同Parallel Scavenge收集器

5.3 使用的垃圾收集算法

标记-整理 算法(老年代)

5.4 应用场景

服务器模式下,虚拟机的 老年代区域

5.5 工作流程

6. CMS收集器

6.1 定义

即Concurrent Mark Sweep,基于 标记-清除算法的收集器

6.2 特点

6.2.1 优点
  • 并行

用户线程 & 垃圾收集线程同时进行。即在进行垃圾收集时,用户还能工作(重点)。

  • 单线程收集

只使用一条线程完成垃圾收集(GC线程)

垃圾收集停顿时间短

该收集器的目标是: 获取最短回收停顿时间 , 即希望 系统停顿的时间 最短,提高响应速度

6.2.2 缺点
  • 总吞吐量会降低

因为该收集器对CPU资源非常敏感,在并发阶段,虽不会导致用户线程停顿,但会因为占用部分线程(CPU资源)而导致应用程序变慢,总吞吐量会降低

  • 无法处理浮动垃圾

由于并发清理时用户线程还在运行,所以会有新的垃圾不断产生(即浮动垃圾),只能等到留待下一次GC时再清理掉。

  1. 因为这一部分垃圾出现在标记过程之后,所以CMS无法在当次GC中处理掉它们
  2. 因此,CMS无法等到老年代被填满再进行Full GC,CMS需要预留一部分空间。即所谓的:可能出现Concurrent Mode Failure失败而导致另一次Full GC产生。
  • 垃圾收集后会产生大量内存空间碎片

6.3 使用的垃圾收集算法

标记-清除 算法(老年代)

6.4 应用场景

重视应用的响应速度、希望系统停顿时间最短的场景

如互联网移动端应用

6.5 工作流程

CMS收集器是基于标记-清除算法实现的收集器,工作流程较为复杂:(分为四个步骤)

  1. 初始标记:为了收集应用程序的对象引用需要暂停应用程序线程,该阶段完成后,应用程序线程再次启动。
  2. 并发标记:从第一阶段收集到的对象引用开始,遍历所有其他的对象引用。
  3. 并发预清理:改变当运行第二阶段时,由应用程序线程产生的对象引用,以更新第二阶段的结果。
  4. 重标记:由于第三阶段是并发的,对象引用可能会发生进一步改变。因此,应用程序线程会再一次被暂停以更新这些变化,并且在进行实际的清理之前确保一个正确的对象引用视图。这一阶段十分重要,因为必须避免收集到仍被引用的对象。
  5. 并发清理:所有不再被应用的对象将从堆里清除掉。
  6. 并发重置:收集器做一些收尾的工作,以便下一次 GC 周期能有一个干净的状态。

7. G1收集器

7.1 定义

最新、技术最前沿的垃圾收集器

7.2 特点

  • 并行
    用户线程 & 垃圾收集线程同时进行。

即在进行垃圾收集时,用户还能工作

  • 多线程
    即使用 多条垃圾收集线程(GC线程) 进行垃圾收集

并发 & 并行 充分利用多CPU、多核环境下的硬件优势 来缩短 垃圾收集的停顿时间

  • 垃圾回收效率高

G1 收集器是 针对性 对 Java堆内存区域进行垃圾收集,而非每次都对整个 Java 堆内存区域进行垃圾收集。

  1. 即 G1收集器除了将 Java 堆内存区域分为新生代 & 老年代之外,还会细分为许多个大小相等的独立区域( Region),然后G1收集器会跟踪每个 Region里的垃圾价值大小,并在后台维护一个列表;每次回收时,会根据允许的垃圾收集时间 优先回收价值最大的Region,从而避免了对整个Java堆内存区域进行垃圾收集,从而提高效率。
  2. 因为上述机制,G1收集器还能建立可预测的停顿时间模型:即让 使用者 明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得从超出N毫秒。即具备实时性
  • 分代收集

同时应用在 内存区域的新生代 & 老年代

  • 不会产生内存空间碎片
  1. 从整体上看,G1 收集器是基于 标记-整理算法实现的收集器
  2. 从局部上看,是基于 复制算法 实现

上述两种算法意味着 G1 收集器不会产生内存空间碎片。

7.3 使用的垃圾收集算法

  • 对于新生代:复制算法
  • 对于老年代:标记 - 整理算法

7.4 应用场景

服务器端虚拟机的内存区域(包括新生代 & 老年代)

7.5 工作流程

G1 收集器的工作流程分为4个步骤:

  1. 初始标记,整个过程STW,标记了从GC Roots 的可达对象
  2. 并发标记 ,真个过程用户线程的垃圾回收线程共同执行,标记出GC Roots可达对象的关联对象,收集整个Region的存活对象。
  3. 最终标记,整个过程STW,标记出并发标记遗漏的,以及引用关系发生变化的存活对象。
  4. 筛选回收,垃圾清理过程,如果整个Region没有存活对象,将Region加入到存活列表当中。
123

Wfc

23 日志
5 分类
6 标签
GitHub E-Mail Weibo wszgkm
© 2019 Wfc
由 Hexo 强力驱动 v3.9.0
|
主题 – NexT.Gemini v7.1.2