ARTS打卡第五周
A
R
最近没有看论文,被拒稿了,心情还是有点焦虑的,论文还是要好好看的,准备接下来好好看下师妹整理的过滤泡的文件
T
关于hexo的文字统计,参考了老马的博客在做下
这里有 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即可,详情可以百度前缀和
由于计算机的存储设备与处理器的运算速度有好几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
为了解决缓存一致性问题,需要各个处理器访问缓存时都遵守一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等。而Java虚拟机也有自己的内存模型。
除了增加了高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行结果进行重组,保证结果与顺序执行的结果是一致的,但不保证程序中的各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后执行顺序来保证。
与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序优化。
Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。
Java内存模型规定所有变量都存储在主存(Main Memory)中(虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory),线程的工作内存保存了被线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取/赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主存来完成。
注意:这里所讲的主内存、工作内存与前面讲解Java内存区域中的Java堆、栈、方法区等不适同一个层次划分,两者无任何关系。
如果两者一定要勉强对应起来,那从变量/主内存/工作内存的定义来看,主内存主要对应于Java堆中对象的实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低的层次来说,主存就是硬件的内存,而为获取更好的运算速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存。
注意: Java内存模型只要求上述两个操作必须按照顺序执行,而没有保证是连续执行。也就是说,read 和 load 之间、store 和 write 之间是可插入其他指令的。如对内存中的变量a、b 进行访问时,一种可能出现的顺序是 :read a、read b、load b、load a 。
除此之外,Java内存模型还规定在执行上述8种操作需满足的规则:
可见性
当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量做不到这一点,其在线程间传递需要通过主内存来完成
禁止指令重排序
volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,仍然需要通过加锁保证原子性:
(1)运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值;
(2)变量不需要与其他的状态变量共同参与不变约束。
volatile指令操作,它的作用相当于一个内存屏障(Memory Barrier),指重排序时不能把后面的指令重排序到内存屏障之前的位置。当两个或以上的CPU访问同一块内存时,需要内存屏障来保证一致性。
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。
Java内存模型围绕着在并发过程中如何处理原子性、可见性、有序性这三个特征来建立的。
定义:由于Java内存模型来直接保证的原子性变量操作包括 read,load,assign,use,store和write,我们大致认为基本数据类型的访问读写数据是具备原子性的。
更大范围的原子性保证:如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock 和 unlock 操作来满足这些需求,尽管虚拟机没有把lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作。
synchronized关键字:monitorenter 和 monitorexit 这两个字节码指令反映到java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。
可见性:指当一个线程修改了共享变量的值,其他能够立即得知这个修改。
定义:Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此
普通变量与 volatile变量的区别:volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,所以volatile保证了多线程操作时变量的可见性,普通变量则不能保证这一点。
synchronized 和 final关键字:除了volatile关键字外,Java还有两个关键字实现可见性: synchronized 和 final。
synchronized同步块的可见性: 是由对一个变量执行unlock 操作前,必须先把此变量同步回主内存中;
final关键字的可见性:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this 的引用传递出去(this引用传递很危险,其他线程很有可能通过此引用访问到“初始化了一半”的对象),那在其他线程中就能看见final 字段的值。
如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指 “线程内表现为串行”的语义,后半句是指 “指令重排序”现象和“工作内存与主内存同步延迟”现象。
volatile和 synchronized关键字保证了线程间操作的有序性:
先行发生原则定义:先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A 先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到, 影响包括 修改了内存中共享变量的值,发送了消息,调用了方法等。
线程其实是比进程更轻量级的调度执行单位。线程的引入,可以把一个检查的资源分配和执行调度分开,各个线程既可以共享资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)。
实现线程的3种方式分别是:使用内核线程实现;使用用户线程实现;使用用户线程加轻量级进程混合实现。
内核线程:
定义:直接由操作系统内核支持的线程。
原理:
优点:每个轻量级进程都由一个内核线程支持,因此每个都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞,也不会影响整个进程继续工作。
缺点:由于基于内核线程实现,所以各种线程操作(创建、析构及同步)都需要进行系统调用,代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换;另外,一个系统支持轻量级进程的数量是有限的。
用户线程:
用户线程和轻量级进程混合:
Java线程:
JDK1.2前使用基于称为“绿色线程”的用户线程实现的,1.2以后替换为基于操作系统原生线程模型实现。 Sun JDK来说,它的Windows版本与Linux版本都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程中,因为Windows和Linux系统提供的线程模型就是一对一的
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种:
协同式线程调度和抢占式线程调度。
协同式线程调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。
优点:实现简单,无线程同步问题(因为都是要把自己的事情干完才会进行线程切换);
缺点:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序会一直阻塞在那里。
抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的就是抢占式调度。
虽然Java 线程调度是系统自动完成的,但我们还是可以建议系统给某些线程多分配一点执行时间,另外一些线程则可以少分配一点——这项操作可以通过设置线程优先级来完成。Java语言一共设置了10个级别的优先级,在两个线程同时处于Ready状态,优先级越高的线程越容易被系统选择执行。
不过线程优先级并不是太靠谱,原因是因为Java的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于 操作系统,虽然现在很多os 都提供了线程优先级,但不见得和 能与 java线程的优先级一一对应。如 Solaris中有 2^32 种优先级,而windows只有7种 。
新建(New):创建后尚未启动的线程处于这个状态。
运行(Runnable): Runable包括了os 线程状态中的 Running 和 Ready,也就是处于 此状态的线程有可能正在执行,也有可能正在等待着CPU 为它分配执行时间。
无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式的唤醒。以下方法会让线程陷入无限期的等待状态:
1 | 没有设置Timeout参数的Object.wait()方法; |
1 | Thread.sleep() 方法; |
阻塞(Blocked):线程被阻塞了, “阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
结束(Terminated):已经终止线程的线程状态,线程已经结束执行。
代码本身封装了所有必要的正确性保障手段(如互斥同步),令调用者无须关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。
(1)定义:
例如 java.lang.String类的对象:它是一个典型的不可变对象,调用它的substring(), replace(), concat() 这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
(2)保证对象行为途径
途径有多种,其中最简单的就是把对象中带有状态的变量都声明为final,这样在构造函数之后,它就是不可变的。
(1)定义
一个类要达到“不管运行环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
例如 java.util.Vector 是一个线程安全的容器,因为它的add()方法、get()方法、size()方法这些方法都是被synchronized修饰的,尽管效率低下,但确实是安全的。可以即使如此并不意味着调用它的时候不需要同步手段了.
但是有时候还会抛出异常,抛出异常的原因:因为如果另一个线程恰好在错误的时间里删除了一个元素,导致序号i 已经不再可用的话,再用i 访问数组就会抛出一个 ArrayIndexOutOfBoundsException。
(1)定义
相对线程的安全就是通常意义上所讲的线程安全,它需要保证对这个对象单独操作是线程安全的。开发人员在调用的时候不需要做额外保障措施,但是对于一些特定顺序连续调用,就需要在调用端使用额外的同步手段来保证调用正确性。
(1)定义
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。平常所说的一个类不是线程安全,绝大多数指这种情况。
Java API 大部分类属于线程兼容的,如之前的Vector和 HashTable相对应的集合类ArrayList 和 HashMap等。
指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。
注意:由于java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常是有害的,应当尽量避免。
例如Thread类的suspend() 和 resume() 方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都是存在死锁风险的。
(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() 方法即可。
互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,称为阻塞同步。
(1)定义
基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了。如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施,这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为 非阻塞同步。
(2)硬件指令集
为什么使用乐观并发策略需要“硬件指令集的发展”才能进行呢?因为我们需要操作和冲突检测这两个步骤具备原子性,靠什么来保证呢?
是硬件,它保证一个从语义上看起来需要多次操作的行为只通过一次处理器指令就能完成,这类指令常用的有:
(3)CAS 操作避免阻塞同步
如何使用CAS 操作来避免阻塞同步:原子类。
(4)CAS操作(比较并交换操作)的ABA问题
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就说它的值没有被其他线程改变过了吗? 如果在这段期间它的值曾经被改为了B,之后又改回了A,那CAS操作就会误认为它从来没有被改变过,这个漏洞称为 CAS操作的 ABA问题。
(5)解决方法
J.U.C 包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。不过目前来说这个类比较鸡肋, 大部分情况下 ABA问题 不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的,下面介绍其中的两类。
(1)可重入代码(Reentrant Code)
也叫作纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。
所有的可重入代码都是线程安全的;
如何判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
(2)线程本地存储(Thread Local Storage)
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能够保证在同一线程中执行? 如果能保证,我们就可以把共享数据的可见范围限制在同一个线程内,这样,无需同步也可以保证线程间不出现数据争用问题。
(1)定义
若物理机有一个以上的处理器,能让两个或以上的线程同时执行,就可以让后面请求锁的线程“稍等”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
(2)自旋等待时间限度
自旋等待不能代替阻塞,它本身虽避免了线程切换的开销,但仍要占用处理器时间。因此时间占用越短,效果越好,反之自旋的线程只会白白消耗处理器资源,带来性能浪费。
自旋等待的时间必须要有一定的限度, 如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10,用户可以用参数 -XX:PreBlockSpin 来更改。
(3)自适应自旋锁
JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。
如果对于某个锁,自旋很少成功获得过, 那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。
(1)定义
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检查到不可能存在共享数据竞争的锁进行消除。
(2)锁消除的主要判定依据
来源于逃逸分析的数据支持,如果判定在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
(3)Java程序中“默认”存在的同步操作
程序员应该很清楚,怎么会明知道不存在数据争用的情况下要求同步呢?需要注意,许多同步措施不是程序员自己加的,而是某些而Java程序自带的。
(1)同步操作数量尽可能少原则
在编写代码时,总是推荐同步块的作用范围尽可能小——-只在共享数据的实际作用域中才进行同步,这是为了同步操作数量尽可能少,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。
(2)问题
大多数情况下,上面原则正确。如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
(3)解决方法——锁粗化
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
(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 操作,因此在有竞争的情况下, 轻量级锁会比传统的重量级锁更慢。
(1)定义与目的
目的:消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。
定义: 如果说轻量级锁是在无竞争的情况使用CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS 操作都不做了。
(2)偏向锁中的“偏”
它的意思是这个锁会偏向于 第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
(3)偏向锁的原理
若当前虚拟机启用了偏向锁,那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为01, 即偏向模式。同时使用CAS 操作把获取到这个锁的线程的ID 记录在对象的 Mark Word之中,如果 CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
(4)偏向锁、轻量级锁的状态转换及对象Mark Word的关系
当有另一个线程去尝试获取这个锁时,偏向模式就结束了。根据锁对象目前是否处于被锁定的状态, 撤销偏向后恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。
偏向锁、轻量级锁的状态转换及对象Mark Word的关系如图:
所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。本节将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
栈帧(Stack Frame) 是用于支持虚拟机方法调用和方法执行的数据结构,它是虚拟机运行时数据区中虚拟机栈(Virtual Machine Stack)的栈元素。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始到执行完成的过程,就对应一个栈帧在虚拟机栈里从入栈到出栈的过程。
在编译程序代码的时候,栈帧需要多大的局部变量表、多深的操作数栈都已经完全确定了,因此一个栈帧需要分配多少内存不会受到运行期变量数据的影响。
活动线程中只有栈顶的栈帧是有效的,称为当前栈帧,执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。
在活动线程中,只有位于栈顶的栈帧才是有效的,成为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
Java 虚拟机每调用一个 Java 方法,便会创建一个栈帧。
这种栈帧有两个主要的组成部分,分别是局部变量区,以及字节码的操作数栈。这里的局部变量是广义的,除了普遍意义下的局部变量之外,它还包含实例方法的“this指针”以及方法所接收的参数。
局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。程序编译完成时,就在方法的的Code属性的max_locals数据项中定义了该方法的所需分配的局部变量表的最大容量。
在 Java 虚拟机规范中,局部变量区等价于一个数组,并且可以用正整数来索引。除了 long、double 值需要用两个数组单元来存储之外,其他基本类型以及引用类型的值均占用一个数组单元。
也就是说,boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的,和引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。
当然,这种情况仅存在于局部变量,而并不会出现在存储于堆中的字段或者数组元素上。对于 byte、char 以及 short 这三种类型的字段或者数组单元,它们在堆上占用的空间分别为一字节、两字节,以及两字节,也就是说,跟这些类型的值域相吻合。
局部变量表的容量以变量槽(slot)为最小单位,一个slot的内存占用并不确定,但每个slot应该能存放下boolean、byte、char、short、int、float、reference或者returnAddress类型的数据,这些可以使用32位或者更小的物理内存来存放,但允许slot的长度随着处理器、操作系统或者虚拟机的不同而发生变化。
虚拟机对reference类型的要求:一是从此引用中直接或者间接地查找到对象在Java堆中的数据存放的起始索引地址;二是此引用中直接或者间接地查找到对象所属数据类型在方法区中的存储的类型信息。
对于64位数据类型(long、double),虚拟机以高位对齐的方式为其分配两个连续的slot空间。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型,就代表会同时使用n和n+1这两个Slot。
为了节省栈帧空间,局部变量Slot可以重用,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码PC计数器的值超出了某个变量的作用域,那么这个变量的Slot就可以交给其他变量使用。这样的设计会带来一些额外的副作用,比如:局部变量表作为 GC Roots的一部分,如果局部变量表中对象的引用一直存在或者没有被替换,该对象就不会被GC 回收。
局部变量不会有“准备阶段”,即不会赋予它初始值,一个局部变量如果定义了但是没有赋初始值是不能使用的。
操作数栈也常被称作操作栈,他是后入先出的栈。操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。
32位的数据类型所占栈容量为1,64位数据类型的栈容量为2。在方法执行的时候,操作数栈的深度不会超过在max_stacks中设定的最大值。
Java 虚拟机的算数运算几乎全部依赖于操作数栈。也就是说,我们需要将堆中的 boolean、byte、char 以及 short 加载到操作数栈上,而后将栈上的值当成 int 类型来运算。
当一个方法刚刚开始执行时,方法的操作数栈为空,在方法执行过程中,字节码指令向操作数栈写入和提取内容,也就是出栈、入栈操作。
在概念模型中,一个活动线程中两个栈帧是相互独立的。但大多数虚拟机实现都会做一些优化处理:让下一个栈帧的部分操作数栈与上一个栈帧的部分局部变量表重叠在一起,这样的好处是方法调用时可以共享一部分数据,而无须进行额外的参数复制传递。
每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,这个引用为了支持调用过程中动态链接(Dynamic linking)。
字节码中方法调用指令是以常量池中的指向方法的符号引用为参数的,有一部分符号引用会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为 静态解析,另外一部分在每次的运行期间转化为直接引用,这部分称为动态连接。
当一个方法被执行后,有两种方式退出这个方法:
无论采用何种退出方式,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
虚拟机规范允许虚拟机实现向栈帧中添加一些自定义的附加信息,例如与调试相关的信息等。
二、方法调用
方法调用阶段唯一的任务就是确定被调用方法的版本(调用的是哪一个方法),暂时还不涉及方法内部的具体运行过程。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于直接引用)这个特性为java带来了更强大的动态扩展能力。
解析
所有方法调用中的目标方法在Class文件里面都是一个常量池的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的条件是方法在程序真正运行之前就有一个可确定的版本,并且这个方法的调用版本在运行期是不可变的。即调用目标在程序代码写好、编译器进行编译时就必须确定下来。这种调用称为解析。
符合”编译器可知,运行期不变“的方法,主要是静态方法和私有方法。静态方法与类型直接相关,私有方法在外部不可能被访问,因此这两种方法不可能通过继承或别的方法重写其他版本,因此适合在类加载阶段进行解析。
在Java虚拟机中提供了5条方法调用字节码指令:
invokestatic : 调用静态方法
invokespecial: 调用实例构造器方法、私有方法、父类方法
invokevirtual: 调用所有的虚方法
invokeinterface: 调用接口方法,会在运行时在确定一个实现此接口的对象
invokedynamic: 先在运行时动态解析出点限定符所引用的方法, 然后再执行该方法,在此之前的4条调用命令的分派逻辑是固化在Java虚拟机内部的, 而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合条件的有静态方法、私有方法、实力构造器、父类方法4类。它们在类加载的时候就会把符号引用解析为该方法的直接引用。 这些方法称为非虚方法。final修饰的方法也是非虚方法。
解析是个静态的过程,编译期间就完全确定,在类加载的解析阶段会把涉及的符号引用全部转为直接引用,不会延迟到运行期再去完成
虚拟机如何调用方法的内容已经讲解完毕,现在我们来探讨虚拟机是如何执行方法中的字节码指令。
Java语言经常被人们定位为“解释执行”语言,在Java初生的JDK1.0时代,这种定义还比较准确的,但当主流的虚拟机中都包含了即时编译后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出来了直接生成本地代码的编译器[如何GCJ(GNU Compiler for the Java)],而C/C++也出现了通过解释器执行的版本(如CINT),这时候再笼统的说“解释执行”,对于整个Java语言来说就成了几乎没有任何意义的概念。
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寄存器里面。
两套指令集的优缺点:
基于栈的指令集主要的优点就是可移植性,不会受硬件的不同而受影响。而基于寄存器的指令集,程序直接依赖这些硬件寄存器,不同的硬件设备,则不可避免受到约束。而使用栈架构的指令集,用户程序不会直接使用这些寄存器,而是由虚拟机来完成与寄存器的交互,从而避免直接与硬件交互。但基于栈指令集的主要缺点是执行速度相对来说会稍慢一些。而相对的基于寄存器指令集的执行速度会相对较优。
通过如下代码解释基于栈的解释器执行过程:
1 | public int calc() { |
使用javap命令后得如下反编译代码:
1 | public int calc(); |
javap提示这段代码需要深度为2的操作数栈和4个Slot的局部变量空间,根据这些信息总共可绘制7张图来描述程序执行过程中的代码、 操作数栈和局部变量表的变化情况:
Java虚拟机指令由一个字节长度的、代表某种特定操作含义的数字(操作码)以及跟随其后的零至多个代表此操作所需的参数构成。
Java虚拟机的指令由一个字节长度,代表着某种特定操作含义的数字(称为操作码)以及跟其随后的零至多个代表此操作所需参数(称为操作数)而构成。Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数指令都不包含操作数。因为字节码指令只有一个字节,所以指令集的操作码总数不可能超过256条。
在Java虚拟机中,大多数的指令都包含了其对操作所对应的数据类型信息。对于大部分与数据类型相关的字节码指令,他们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务。i代表int l代表long,s代表short,b代表byte,c代表char,f代表float,a代表reference.
加载存储指令用于将数据在帧栈中的局部变量表和操作数栈进行来回传输。
①将一个局部变量加载到操作栈:
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.
运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。注意:由于没有直接支持byte,short,char和boolean类型的算术运算指令,对这种类型直接转换为int进行运算。特别需要注意的是:数据运算可能会导致溢出对象,Java虚拟机在整数没有定义数据异常,所以整数异常不报异常,注意在编程中范围。
注意:
Java虚拟机在处理浮点数运算时,不会抛出任何运行时异常,当一个操作产生溢出时,将会使用有符号的无穷大来表示。操作结果没有明确的数学定义的话,将会时候NaN值来表示。
在处理整型数据时,只有除法指令(idiv和ldiv)以及求余指令(irem和lrem)出现除数为零时会导致虚拟机抛出异常
类型转换指令可以将两种不同的数值类型进行相互转换,一般用于实现用户代码中的显式类型转换操作。
一、
二、
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。
控制转移指令可以让Java虚拟机有条件或无条件地从指定指令而不是控制转移指令的下一条指令继续执行程序。控制转移指令包括有:
java虚拟机提供了一些用于直接操作操作数栈的指令
方法调用:
在程序中显式抛出异常的操作会由athrow指令实现,除了这种情况,还有别的异常会在其他Java虚拟机指令检测到异常状况时由虚拟机自动抛出。
athrow :显式抛出异常指令。
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。
同步一段指令集序列通常是由Java语言中的synchronized块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义.
monitorenter、monitorexit:支持synchronized语句块语义的指令。
具体来说,Java 字节码中与调用相关的指令共有五种。
1 | interface 客户 { |
Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指令,而接口方法调用都会 被编译成 invokeinterface 指令。这两种指令,均属于Java 虚拟机中的虚方法调用。
在绝大多数情况下,Java 虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法。这个过程我们称之为动态绑定。那么,相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时。
在 Java 虚拟机中,静态绑定包括用于调用静态方法的 invokestatic 指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的 invokespecial 指令。如果虚方法调用指向一个标记为 final 的方法,那么 Java 虚拟机也可以静态绑定该虚方法调用的目标方法。
Java 虚拟机中采取了一种用空间换取时间的策略来实现动态绑定。它为每个类生成一张方法表,用以快速定位目标方法。
类加载的准备阶段,它除了为静态字段分配内存之外,还会构造与该类相关联的方法表。
这个数据结构,便是 Java 虚拟机实现动态绑定的关键所在。下面我将以 invokevirtual 所使用的虚方法表(virtual method table,vtable)为例介绍方法表的用法。invokeinterface 所使用的接口方法表(interface method table,itable)稍微复杂些,但是原理其实是类似的。
方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。
这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:
其一,子类方法表中包含父类方法表中的所有方法;
其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。
我们知道,方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。
在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。
使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。相对于创建并初始化 Java 栈帧来说,这几个内存解引用操作的开销简直可以忽略不计
那么我们是否可以认为虚方法调用对性能没有太大影响呢?
其实是不能的,上述优化的效果看上去十分美好,但实际上仅存在于解释执行中,或者即时编译代码的最坏情况中。这是因为即时编译还拥有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)。
内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。
即:
Java 虚拟机中的即时编译器会使用内联缓存来加速动态绑定。Java 虚拟机所采用的单态内联缓存将纪录调用者的动态类型,以及它所对应的目标方法。
当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。否则,Java 虚拟机将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定。
在针对多态的优化手段中,我们通常会提及以下三个术语。
对于内联缓存来说,我们也有对应的单态内联缓存、多态内联缓存和超多态内联缓存。单态内联缓存,顾名思义,便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。
多态内联缓存则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。
一般来说,我们会将更加热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,Java 虚拟机只采用单态内联缓存。
前面提到,当内联缓存没有命中的情况下,Java 虚拟机需要重新使用方法表进行动态绑定。对于内联缓存中的内容,我们有两种选择。一是替换单态内联缓存中的纪录。这种做法就好比 CPU 中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。
因此,在最坏情况下,我们用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。
另外一种选择则是劣化为超多态状态。这也是 Java 虚拟机的具体实现方式。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存纪录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。
虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。
对于极其简单的方法而言,比如说 getter/setter,这部分固定开销占据的 CPU 时间甚至超过了方法本身。此外,在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性.
在编译过程中,我们并不知道目标方法的具体内存地址。因此,Java 编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。
符号引用存储在 class 文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。
对于非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找。
对于接口符号引用,假定该符号引用所指向的接口为 I,则 Java 虚拟机会按照如下步骤进行查找。
经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。
Java虚拟机规范描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集。这些内容与硬件、操作系统及具体的Java虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把他们看作是程序在各种Java平台实现之间互相安全的交互的手段。
理解公有设计与私有实现之间的分界线是非常有必要的,Java虚拟机实现必须能够读取Class文件并精确实现包含在其中的Java虚拟机代码的语义。拿着Java虚拟机规范一成不变的逐字实现其中要求的内容当然是一种可行的途径,但一个优秀的虚拟机实现,在满足虚拟机规范的约束下对具体实现做出修改和优化也是完全可行的,并且虚拟机规范中明确鼓励实现者这样做。只要优化后Class文件依然可以被正确读取,并且包含在其中的语义能得到完整的保持,那实现者就可以选择任何方式去实现这些语义,虚拟机后台如何处理Class文件完全是实现者自己的事情,只要他在外部接口上看起来与规范描述的一致即可。
虚拟机实现者可以使用这种伸缩新来让Java虚拟机获得更高的性能、更低的内存消耗或者更好的可移植性,选择哪种特性取决于Java虚拟机实现的目标和关注点是什么。虚拟机实现的方式主要有以下两种:
精确定义的虚拟机和目标文件格式不应当对虚拟机实现者的创造性产生太多的限制,Java虚拟机因被设计成可以允许有众多不同的实现,并且各种实现可以在保持兼容性的同时提供不同的、新的、有趣的解决方案。
定义:确定执行哪个方法的过程
分类:静态分派 & 动态分派
a. 疑问
方法的执行不是取决于代码设置中的执行对象吗?为什么还要选择呢?
b. 回答
1 | public class Test { |
变量的静态类型 = 引用类型 :不会被改变、在编译器可知
变量的动态类型 = 实例对象类型 :会变化、在运行期才可知
定义:
应用场景
方法重载(OverLoad) = 静态分派 = 根据 变量的静态类型确定执行(重载)哪个方法。
什么是重载?
如果我们想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同。这些方法之间的关系,我们称之为重载。重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:
如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。
1 | public class Test { |
特别注意
a. 变量的静态类型发生变化的情况
可通过强制类型转换改变变量的静态类型
1 | Human man = new Man(); |
b. 静态分派的优先级匹配问题
问题描述:
背景:现需要进行静态分派
问题:程序中没有显示指 静态类型
解决方案:程序会根据静态类型的优先级从而选择优先的静态类型进行方法分配。
特别注意
注意只跟其编译时类型(即静态类型)相关
优先级顺序为:
1 | //比如: |
根据 变量的动态类型进行方法分派的行为
即根据 变量的动态类型确定执行哪个方法
方法重写(Override)
如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java 虚拟机才会判定为重写。
对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成桥接方法 来实现 Java 中的重写语义。
由于对重载方法的区分在编译阶段已经完成,我们可以认为 Java 虚拟机不存在重载这一概念。
确切地说,Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
1 | // 定义类 |
invokevirtual指令执行的第一步 = 确定接受者的实际类型
invokevirtual指令执行的第二步 = 将 常量池中 类方法符号引用 解析到不同的直接引用上
第二步即方法重写(Override)的本质
1 | 例子:静态+动态 |
1 | 例子:动态+初始化编译 |
方法的接收者、方法的参数都可以称为方法的宗量。根据分批基于多少种宗量,可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择的,多分派是根据多于一个的宗量对目标方法进行选择的。
Java在进行静态分派时,选择目标方法要依据两点:一是变量的静态类型是哪个类型,二是方法参数是什么类型。因为要根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
运行时阶段的动态分派过程,由于编译器已经确定了目标方法的签名(包括方法参数),运行时虚拟机只需要确定方法的接收者的实际类型,就可以分派。因为是根据一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
由于动态分派是非常频繁的动作,而动态分派在方法版本选择过程中又需要在方法元数据中搜索合适的目标方法,虚拟机实现出于性能的考虑,通常不直接进行如此频繁的搜索,而是采用优化方法。
其中一种“稳定优化”手段是:在类的方法区中建立一个虚方法表(Virtual Method Table, 也称vtable, 与此对应,也存在接口方法表——Interface Method Table,也称itable)。使用虚方法表索引来代替元数据查找以提高性能。其原理与C++的虚函数表类似。
通过虚方法表存放各个方法的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口;如果子类中重写了这个方法,子类方法表中的地址将会诶替换为指向子类实现版本的入口地址。
即实现类加载过程中“加载”环节里“通过类的全限定名来获取定义此类的二进制字节流”的功能(类加载5个阶段)
1 | /例子 |
重点为:
仅按文件名识别,如:rt.jar中,名字不符合的类库即使放在LIB目录中也不会被加载
1 | @CallerSensitive |
负责加载用户类路径(ClassPath)上所指定的类库
各种类加载器的使用并不是孤立的,而是相互配合使用
在Java虚拟机中,各种类加载器配合使用的模型(关系)是双亲委派模型
作用:
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。因此,使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处:类随着它的类加载器一起具备了一种带有优先级的层次关系。
双亲委派模型的工作流程代码实现在 java.lang.ClassLoader中的的loadClass()中
1 | //具体如下 |
各个类加载器之间是组合关系,并非继承关系。
步骤总结:若一个类加载器收到了类加载请求
该类把加载请求委派给父类加载器去完成,而不会自己去加载该类:每层的类加载器都是如此,因此所有的加载请求最终都应传送到顶层的启动类加载器中
只有当父类加载器反馈自己无法完成该加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会自己去加载
以自定义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的体系中最基础的行为就无法保证。
以下代码中的FileSystemClassLoader是自定义类加载器,继承自java.lang.ClassLoader,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class文件),然后读取该文件内容,最后通过defineClass()方法来把这些字节代码转换成java.lang.Class类的实例。
java.lang.ClassLoader的loadClass()实现了双亲委派模型的逻辑,自定义类加载器一般不去重写它,但是需要重写findClass()方法。
1 | public class FileSystemClassLoader extends ClassLoader { |
1 | //主要是通过继承自ClassLoader类 从而自定义一个类加载器 MyClassLoader.java |
假如我们的类不在类路径下,而我们又想读取一个自定义的目录下的类,如果做呢?
//示例读取c:/test/com/test.jdk/Key.class这个类。
1 | package com.test.jdk; |
1 | //自定义类加载器正常加载到类,程序最后输出:111111 |
URLClassLoader上面自定义一个类加载器来读取自定义的目录,其实可以直接使用URLClassLoader就能读取,它已经实现了路径下类的读取逻辑
1 | public static void main(String[] args) { |
双亲委派模型并不是一个强制性的约束模型。
双亲委派模型主要出现过3次较大规模“被破坏”的情况。
第一次破坏是因为类加载器和抽象类java.lang.ClassLoader中在JDK1.0就存在的,而双亲委派模型在JDK1.2之后才被引入,为了兼容已经存在的用户自定义类加载器,引入双亲委派模型时做了一定的妥协:在java.lang.ClassLoader中中引入了一个的findClass()方法,在此之前,用户去继承java.lang.ClassLoader中的唯一目的就是重写的loadClass()方法.JDK1 0.2之后不提倡用户去覆盖的loadClass()方法,而是把自己的类加载逻辑写到的findClass()方法中,如果的loadClass()方法中如果父类加载失败,则会调用自己的的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型规则的。
第二次破坏是因为模型自身的缺陷,现实中存在这样的场景:基础的类加载器需要求调用用户的代码,而基础的类加载器可能不认识用户的代码为此,Java的设计团队引入的设计时“线程上下文类加载器(Thread Context ClassLoader)”。这样可以通过父类加载器请求子类加载器去完成类加载动作。已经违背了双亲委派模型的一般性原则。
第三次破坏是由于用户对程序动态性的追求导致的。这里所述的动态性是指:“代码热替换”,“模块热部署”等等比较热门的词。说白了就是希望应用程序能够像我们的计算机外设一样,接上鼠标,U盘不用重启机器就能立即使用.OSGi是当前业界“事实上”的Java的模块化标准,OSGi的实现模块化热部署的关键是它自定义的类加载器机制的实现。每一个程序模块(OSGi的中称为包)都有一个自己的类加载器,当需要更换一个包时,就把束连同类加载器一起换掉以实现代码的热替换。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。当收到类加载请求时,osgi将按照下面的顺序进行类搜索:
他们都能在运行时对任意一个类,都能够知道该类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性。
加载
链接(校验准备解析)
初始化
Classloder.loaderClass得到的类是还没有链接的
的Class.forName得到的类是已经初始化完成的
调用的时候静代码块直接运行
这样我们需要初始化后才能得到的DriverManager,所以我们选择使用的Class.forName()
将描述类的数据从Class文件加载到内存&对数据进行校验、转换解 和初始化,最终形成可被虚拟机直接使用的Java使用类型:Class文件是一串二进制字节流。
分为七个步骤,其中五个关键步骤:加载 -> 验证 -> 准备 -> 解析 -> 初始化
这几个阶段中的:加载、验证、准备、初始化、卸载的顺序是固定的。但是解析则有可能在初始化之后才开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。
注意:上文所说按部就班地“开始”,而不是“进行”或“完成”,强调这点是因为这些阶段通常都是互相交叉地混合式进行,通常会在一个阶段执行的过程中调用、激活另一个阶段。加载阶段 与 连接阶段 的部分内容(如一部分字节码文件格式验证动作)是交叉进行的。加载阶段尚未完成,连接阶段可能已经开始,但这些加载加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
遇到new、读取一个类的静态字段(getstatic)、设置一个类的静态字段(putstatic)、调用一个类的静态方法(invokestatic)。类没有初始化,那么需要先触发进行初始化。
使用java.lang.reflect包的方法对类进行反射调用时。
当类初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。(如果是接口,则不必触发其父类初始化)
当虚拟机执行一个main方法时,会首先初始化main所在的这个主类。
当只用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。(暂未研究此种场景)
加载,将外部的类文件加载到虚拟机或者存储到方法区内是指查找字节流,并且据此创建类的过程。前面提到,对于数组类来说,它并没有对应的字节流,而是由Java虚拟机直接生成的。对于其他的类来说,Java虚拟机则需要借助类加载器来完成查找字节流的过程。类加载器实现的功能是即为加载阶段获取二进制字节流的时候。
通过一个类的全限定名来获取此类的二进制字节流;
注意:这里的二进制字节流并不只是单纯地从Class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其他文件生成(JSP应用)等。
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
注:“方法区域Java堆一样,是各线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据”。而方法区中的数据存储结构格式虚拟机自行定义。
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
注:加载阶段完成后,虚拟机在内存中实例化一个java.lang.Class类的对象(Class是一个实实在在的对象,是记录着类成员、接口等信息的对象)。还有一点是,我们都知道对象肯定是存放在堆中的,但Class对象比较特殊,对于HotSpot虚拟机而言,Class对象是存放在方法区中的。
非数组类加载过程是开发人员可控性最强的,可以使用系统提供的引导类加载器,也可以由用户自定义类加载器完成(重新一个类加载器 loadClass())。 对于数组类而言,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的,但是数组类的元素类型(Element Type,是指数组去掉所有维度的类型)最终要靠类加载器去创建,一个数组类(简称为C)创建过程要遵循以下规则:
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存实例化一个 java.lang.Class类的对象(并无明确规定是在Java 堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽是对象,但存放在方法区里),这个对象将作为程序访问方法区中的这些类型数据的外部接口。
验证是连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段非常重要,直接决定了虚拟机是否能承受恶意代码的攻击,从执行性能的角度来讲,该阶段的工作量在虚拟机的类加载子系统中占有了相当大一部分。
Java语言本身是相对安全的语言(相对于C/C++),使用纯粹的Java代码无法做到诸如访问数组边界之外的数据、将一个对象转型为它未实现的数据类型、跳转到不存在的代码行等,如果这样做了,编译器将拒绝编译,但是Class文件不一定由Java源码编译而来,完全可以使用任何途径,如:用十六进制编辑器直接编写来产生Class文件,在字节码层面上,上述Java代码无法做到的事情都是可以实现的,此时虚拟机如果不检查输入的字节流,很有可能因为载入了有害的字节流而导致系统崩溃,所以验证时虚拟机对自身保护的一项重要工作。
验证阶段是非常重要的,但不是一定必要的阶段(对程序运行期没有影响),如果运行的全部代码都被反复使用和验证过,那么在实施阶段可以考虑通过参数-Xverify:none 来关闭类验证措施,以缩短虚拟机类加载的时间。
大致都会完成以下四个阶段的验证:
此阶段有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; |
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程
符号引用 (Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。 符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须一致,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用(Direct Refenrences):直接引用可以是直接目标的指针、相对偏移量或是一个能间接定位到目标的句柄。 直接引用是和虚拟机实现的内存布局有关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那么引用的目标必定已经在内存中存在
对同一个符号引用进行多次解析请求是很常见的事情,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。但对于invokedynamic指令,上面规则则不成立。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对其他invokedynamic指令也同样生效。因为invokedynamic指令是JDK1.7新加入的指令,目的用于动态语言支持,它所对应的引用称为“动态调用点限定符”(Dynamic Call Site Specifier),这里“动态”的含义就是必须等到程序实际运行到这条指令的时候,解析动作才能进行。相对的,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有执行代码时就进行解析。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号进行引用,下面只对前4种引用的解析过程进行介绍,对于后面3种与JDK1.7新增的动态语言支持息息相关。
符号引用就是一个类中(当然不仅是类,还包括类的其他部分,比如方法,字段等),引入了其他的类,可是JVM并不知道引入的其他类在哪里,所以就用唯一符号来代替,等到类加载器去解析的时候,就把符号引用找到那个引用类的地址,这个地址也就是直接引用。
将一个类中所有被static关键字标识的代码统一执行一遍,如果执行的是静态变量,那么就会使用用户指定的值覆盖之前在准备阶段设置的初始值;如果执行的是static代码块,那么在初始化阶段,JVM就会执行static代码块中定义的所有操作。
在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。 如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。 除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >。 类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。
那么,类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况:
1 | class Singleton{ |
Singleton输出结果:1 0
原因:
执行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
原因:
执行
public static int value2 = 0;
此时value2=0(value1不变,依然是0);
执行
private static Singleton singleton = new Singleton();
执行Singleton2的构造器:value1++;value2++;
此时value1,value2均等于1,即为最后结果
计算机只认识0和1,我们编写的程序需要经编译器翻译为由0和1构成的二进制文件才能被计算机执行。伴随着虚拟机和大量建立在虚拟机上程序语言的出现,将程序编译为本地字节码文件已不再是唯一的选择,越来越多的程序语言选择了与操作系统无关的,平台中立的格式作为程序编译后的存储格式。 各个不同平台的虚拟机与所有平台都统一使用相同的程序存储格式——字节码,它是构成平台无关性的基石。
在Java中,JVM可以理解的代码就叫做字节码(即扩展名为.class的文件),它不面向任何特定的处理器,只面向虚拟机。Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以Java程序运行时比较高效,而且,由于字节码并不针对一种特定的机器,因此,Java程序无须重新编译便可在多种不同操作系统的计算机上运行。
Clojure(Lisp 语言的一种方言)、Groovy、Scala 等语言都是运行在 Java 虚拟机之上。下图展示了不同的语言被不同的编译器编异常.class文件最终运行在 Java 虚拟机之上。.class文件的二进制格式可以使用 WinHex 查看。
可以说.class文件是不同的语言在 Java 虚拟机之间的重要桥梁,同时也是支持 Java 跨平台很重要的一个原因。
注意:任何一个class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。
class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在class文件之中,中间没有任何分隔符,使得整个class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在,当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。
Class文件格式采用一种类似C语言结构体的伪结构来存储数据,伪结构有两种类型:无符号数和表。
根据 Java 虚拟机规范,类文件由单个 ClassFile 结构组成: 无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的格式,这时称这一系列连续的某一类型的数据为某一类型的集合。
下面详细介绍一下 Class 文件结构涉及到的一些组件。
Class文件字节码结构组织示意图
1 | u4 magic; //Class 文件的标志 |
每个 Class 文件的头四个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件。
程序设计者很多时候都喜欢用一些特殊的数字表示固定的文件类型或者其它特殊的含义。
1 | u2 minor_version;//Class 的小版本号 |
紧接着魔数的四个字节存储的是 Class 文件的版本号:第五和第六是次版本号,第七和第八是主版本号。
高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致。
1 | u2 minor_version;//Class 的小版本号 |
紧接着魔数的四个字节存储的是 Class 文件的版本号:第五和第六是次版本号,第七和第八是主版本号。
高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致。
1 | u2 constant_pool_count;//常量池的数量 |
紧接着主次版本号之后的是常量池,常量池的数量是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 文件)。
关于怎么查常量项
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口,是否为public 或者 abstract类型,如果是类的话是否声明为final等等。
类访问和属性修饰符:
我们定义了一个 Employee 类
1 | package top.snailclimb.bean; |
通过javap -v class类名 指令来看一下类的访问标志。
1 | u2 this_class;//当前类 |
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。
接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按implents(如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中。
怎么查
1 | u2 fields_count;//Class 文件的字段的个数 |
字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。
field info(字段表) 的结构:
字段的 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语言中不管两个字段的数据类型、修饰符是否相同,都不能使用一样的名称;但对于字节码而言,两个字段的描述符不一致,字段名可以相同。
1 | u2 methods_count;//Class 文件的方法的数量 |
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 | u2 attributes_count;//此类的属性表中的属性数 |
在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。
垃圾收集器的定义分类等概念
最基本、发展历史最长的垃圾收集器
在进行垃圾收集时,必须暂停其他所有工作线程(Stop The World),直到收集结束。Stop The World 暂停工作线程 是在用户不可见的情况下进行
并发与并行的区别
只使用 一条线程 完成垃圾收集(GC线程)
复制 算法(新生代)
客户端模式下,虚拟机的 新生代区域
Serial收集器 应用在老年代区域 的版本
并发、单线程、效率高
同Serial收集器,此处不作过多描述
标记-整理 算法(老年代)
在客户端模式下,虚拟机的老年代区域
在服务器模式下:
Serial收集器的多线程版本。
在进行垃圾收集时,必须暂停其他所有工作线程(Stop The World),直到收集结束。 暂停工作线程是在用户不可见的情况下进行
目前,只有ParNew 收集器能与 CMS收集器 配合工作
由于CMS收集器使用广泛,所以该特点非常重要。
关于CMS收集器 下面会详细说明
复制算法(新生代)
ParNew 收集器的升级版
其他收集器的目标是:尽可能缩短垃圾收集时间,
而Parallel Scavenge收集器的目标则是:达到可控制吞吐量
自适应
该垃圾收集器能根据当前系统运行情况,动态调整自身参数,从而达到最大吞吐量的目标。
复制 算法(新生代)
服务器模式下,虚拟机的 新生代区域
Parallel Scavenge收集器 应用在老年代区域 的版本
以达到 可控制吞吐量 为目标、自适应调节、多线程收集
同Parallel Scavenge收集器
标记-整理 算法(老年代)
服务器模式下,虚拟机的 老年代区域
即Concurrent Mark Sweep,基于 标记-清除算法的收集器
用户线程 & 垃圾收集线程同时进行。即在进行垃圾收集时,用户还能工作(重点)。
只使用一条线程完成垃圾收集(GC线程)
垃圾收集停顿时间短
该收集器的目标是: 获取最短回收停顿时间 , 即希望 系统停顿的时间 最短,提高响应速度
因为该收集器对CPU资源非常敏感,在并发阶段,虽不会导致用户线程停顿,但会因为占用部分线程(CPU资源)而导致应用程序变慢,总吞吐量会降低
由于并发清理时用户线程还在运行,所以会有新的垃圾不断产生(即浮动垃圾),只能等到留待下一次GC时再清理掉。
标记-清除 算法(老年代)
重视应用的响应速度、希望系统停顿时间最短的场景
如互联网移动端应用
CMS收集器是基于标记-清除算法实现的收集器,工作流程较为复杂:(分为四个步骤)
最新、技术最前沿的垃圾收集器
即在进行垃圾收集时,用户还能工作
并发 & 并行 充分利用多CPU、多核环境下的硬件优势 来缩短 垃圾收集的停顿时间
G1 收集器是 针对性 对 Java堆内存区域进行垃圾收集,而非每次都对整个 Java 堆内存区域进行垃圾收集。
同时应用在 内存区域的新生代 & 老年代
上述两种算法意味着 G1 收集器不会产生内存空间碎片。
服务器端虚拟机的内存区域(包括新生代 & 老年代)
G1 收集器的工作流程分为4个步骤: