《深入理解Java虚拟机:JVM高级特性与最佳实践》读书笔记

3 篇文章 0 订阅

深入理解Java虚拟机:JVM高级特性与最佳实践

周志明

前言

  • 实现了在任意一台虚拟机上编译的程序都能在任何一台虚拟机上正常运行

  • OpenJDK

1.4.2 Sun HotSpot VM

  • 提起HotSpot VM,相信所有Java程序员都知道,它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。

  • 如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译和OSR(栈上替换)编译动作。通过编译器与解释器恰当地协同工作,

2.2 运行时数据区域

这个很重要

  • 运行时数据区域

  • image-20220615202348702
  • 程序计数器

  • 可以看作是当前线程所执行的字节码的行号指示器。

  • 一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  • 为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器

  • 线程私有

  • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

2.2.2 Java虚拟机栈

  • Java虚拟机栈

一个线程一个栈, 生死与共. 线死栈死.
一个栈多个栈帧. 每个方法入栈都会生成一个栈帧

  • 线程私有

所以不用垃圾回收

  • 它的生命周期与线程相同。

  • 每个方法在执行的同时都会创建一个栈帧(Stack Frame[1])用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

  • 每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

  • 局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

  • 其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常

  • 如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

2.2.3 本地方法栈

  • 本地方法栈

这就是Java虚拟机对不同的系统平台要有不同的版本的原因之一;不同系统平台的native(本地)方法(调用的是系统平台的api)自然是不同的。

  • 它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务

  • 它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务

2.2.4 Java堆

  • Java堆

  • 线程共享

  • 所有的对象实例以及数组都要在堆上分配[1],但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换[2]优化技术将会导致一些微妙的变化发生

  • 由于现在收集器基本都采用分代收集算法

  • Java堆中还可以细分为:新生代和老年代

2.2.5 方法区

  • 方法区

  • 线程共享

八种基本数据类型的静态变量会在方法区开辟空间,将对应的值存储在方法区。如果是引用数据类型,方法区中存储的是对应实例对象在堆中的地址,实例对象还是在堆中创建

  • 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

注意类信息、常量、静态变量
关于常量:
在类定义的过程中,常量系统并不会有零值,所以常量必须是
赋值的(可以直接赋值或者在构造代码块或者构造函数中初
始化赋值都行)
关于静态变量:
静态变量是有零值的。所以定义的时候没有赋值的话,会被
默认为零值。但是final static修饰的不会有零值。

  • 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

  • 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

  • 永久代

  • 现在也有放弃永久代并逐步改为采用Native Memory来实现方法区的规划了

  • 这区域的内存回收目标主要是针对常量池的回收和对类型的卸载

  • 根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

2.2.6 运行时常量池

  • 运行时常量池(Runtime Constant Pool)是方法区的一部分

常量池存放的是字面量和符号引用, 而运行时常量池一般还会存放直接引用

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

而运行时常量池, 是类加载后动态的添加进去的, 是所有加载了的class共享的常量池

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

常量池和运行时常量池的区分
一个JVM会有有且仅有一个方法区. 当这个类编译之后,会形成class文件, class文件中有一块区域表示的就是常量池, 他是属于class文件的.

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

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

2.2.7 直接内存

  • 直接内存

  • 并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用

2.3 HotSpot虚拟机对象探秘

  • 对象的创建

  • 虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程,

  • 对象所需内存的大小在类加载完成后便可完全确定(

  • 还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为

  • 并发情况下也并不是线程安全

  • 解决这个问题有两种方案

  • CAS配上失败重试

  • TLAB

2.3.2 对象的内存布局

  • 对象的内存布局

  • 分为3块区域

  • 对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头包括两部分信息

这就是锁为什么只能加在对象上的原因。因为锁是存在对象头中。

  • 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为"Mark Word"

  • 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为"Mark Word"

  • 对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

  • 接下来的实例数据部分是对象真正存储的有效信息

  • 各种类型的字段内容

2.3.3 对象的访问定位

  • 通过栈上的reference数据来操作堆上的具体对象

  • 目前主流的访问方式有使用句柄和直接指针两种

2.4 实战:OutOfMemoryError异常

  • Java heap space

  • 也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)

2.4.2 虚拟机栈和本地方法栈溢出

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。

  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

  • 操作系统分配给每个进程的内存是有限制的,譬如32位的Windows限制为2GB。虚拟机提供了参数来控制Java堆和方法区的这两部分内存的最大值。剩余的内存为2GB(操作系统限制)减去Xmx(最大堆容量),再减去MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了

  • 减少栈容量来换取更多的线程

  • unable to create new native thread

2.4.3 方法区和运行时常量池溢出

  • String.intern()是一个Native方法

  • PermGen space

  • 方法区溢出也是一种常见的内存溢出异常

  • CGLib字节码增强和动态语言之外,常见的还有:大量JSP或动态产生JSP文件的应用

2.4.4 本机直接内存溢出

  • 本机直接内存溢出

  • Unsafe类的getUnsafe()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有rt.jar中的类才能使用Unsafe的功能

  • 真正申请分配内存的方法是unsafe.allocateMemory()。

第3章 垃圾收集器与内存分配策略

  • 其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;

  • 栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作

  • 因为方法结束或者线程结束时,内存自然就跟随着回收了

  • 而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存

3.2.2 可达性分析算法

  • 可达性分析算法

  • GC Roots"的对象作为起始点

那么GC Roots 到底是什么东西呢?
GCRoots是可达性分析算法的根.
这里还有什么没有包括吗?
还有实例变量引用的对象不算GCRoot
因为如果实例变量如 public Person p = new Person();产生了该对象, 那么实例变量对应的类一定是实例化了. 所以 这时候p的生命周期和它所在的类对象一致. 所以GCRoot不包含实例变量.

  • Java语言中,可作为GC Roots的对象包括下面几种

  • Java语言中,可作为GC Roots的对象包括下面几种

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。

  • 方法区中类静态属性引用的对象。

  • 方法区中常量引用的对象。

  • 本地方法栈中JNI(即一般说的Native方法)引用的对象

3.2.3 再谈引用

  • 引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,

  • 软引用

  • 系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收

  • 弱引用

  • 只能生存到下一次垃圾收集发生之前。

  • 虚引用

  • 完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。

  • 目的就是能在这个对象被收集器回收时收到一个系统通知

3.2.4 生存还是死亡

  • finalize()方法是对象逃脱死亡命运的最后一次机会

3.2.5 回收方法区

  • 在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。

  • 永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

3.3.2 复制算法

  • 每次使用Eden和其中一块Survivor[1]。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1

  • 当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)

  • 如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

3.3.4 分代收集算法

  • 当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法

  • 一般是把Java堆分为新生代和老年代

  • 在新生代中

  • 选用复制算法

  • 只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用

  • 标记—清理”或者“标记—整理”算法来进行回收

3.4 HotSpot的算法实现

  • 枚举根节点时也是必须要停顿的。

3.5 垃圾收集器

  • 如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

3.6 内存分配与回收策略

  • 对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,

  • 对象优先在Eden分配

  • 新生代GC(Minor GC):指发生在新生代的垃圾收集动作

  • 老年代GC(Major GC/Full GC)

  • Major GC的速度一般会比Minor GC慢10倍以上。

3.6.2 大对象直接进入老年代

  • 大对象直接进入老年代

  • 这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制

3.6.3 长期存活的对象将进入老年代

  • 长期存活的对象将进入老年代

  • 默认为15岁

3.6.5 空间分配担保

  • 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。

6.3.6 方法表集合

字段表集合,方法表集合都是属于class文件信息

  • Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,

  • Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,

  • 方法里的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为"Code"的属性里面

7.2 类加载的时机

  • 类加载的时机
  • 整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载
  • 加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,
  • 但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
    • 1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
    • 2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
    • 3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
    • 4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
    • 5)当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

7.3 类加载的过程

  • 类加载的过程

父类优先于子类加载. 在类加载循序中, 首先是加载顶级父类 ,是从上往下的过程. 首先会顶级父类的初始化, 而在初始化之前会先开始加载,验证,准备;加载时吧编译产生的该class文件,加载进方法区,在方法区生成class对象; 然后到准备阶段,准备阶段会把该类的类变量赋上零值(如果是final static,编译器在编译生成class文件是就会对该字段做上ConstantValue标记,到了再准备阶段对final static则直接赋上定义值,并且把该final static存储在方法区中的常量池中(也就是运行时常量池))

  • 加载

  • 加载

  • 从ZIP包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
    从网络中获取,这种场景最典型的应用就是Applet。
    运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass来为特定接口生成形式为"*$Proxy"的代理类的二进制字节流。
    由其他文件生成,典型场景是JSP应用,即由JSP文件生成对应的Class类。

  • 加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,

  • 然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口

7.3.2 验证

  • 验证

  • 符号引用验证

  • 最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生

  • 符号引用验证的目的是确保解析动作能正常执行

7.3.3 准备

  • 准备

  • 为类变量分配内存并设置类变量初始值的阶段

  • 这些变量所使用的内存都将在方法区中进行分配

  • 不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中

  • 特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值

7.3.4 解析

  • 解析

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

  • 解析阶段中所说的直接引用与符号引用又有什么关联呢?

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量

  • 引用的目标并不一定已经加载到内存中。

  • 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

7.3.5 初始化

  • 初始化

  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞[2],在实际应用中这种阻塞往往是很隐蔽的

  • 需要注意的是,其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程唤醒之后不会再次进入<clinit>()方法。同一个类加载器下,一个类型只会初始化一次

7.4.2 双亲委派模型

  • 双亲委派模型

  • 这些类加载器之间的关系一般如图7-2所示。

  • 这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合

  • 实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中,

  • 先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。

  • 如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

7.4.3 破坏双亲委派模型

  • 由于双亲委派模型在JDK 1.2之后才被引入,而类加载器和抽象类java.lang.ClassLoader则在JDK 1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,

  • 双亲委派模型的第二次“被破坏

  • 如果基础类又要调用回用户的代码,那该怎么办?

  • 一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK 1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI,Service Provider Interface)的代码

  • 为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)

  • 线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

  • 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的

  • 代码热替换(HotSwap)、模块热部署

  • OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。

  • OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构

8.2 运行时栈帧结构

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

  • 写入到方法表的Code属性之中[2]

  • 一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧

  • 局部变量表

  • 用于存放方法参数和方法内部定义的局部变量

  • 局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,

  • 虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束[4]

  • 我们已经知道类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值

  • 局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的

8.2.2 操作数栈

就是说操作数栈就是一种先进后出的数据结构,用它实现方法中代码的运算

  • 操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈

  • 操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈

  • 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作

  • 例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的

和基础指令一样

  • 举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈

  • 举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈

  • Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈

8.2.3 动态连接

  • 每个栈帧都包含一个指向运行时常量池[1]中该栈帧所属方法的引用

  • 持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

9.2 案例分析

  • 一个功能健全的Web服务器,要解决如下几个问题

  • 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离

  • 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享。

  • 这主要倒不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到服务器内存,如果类库不能共享,虚拟机的方法区就会很容易出现过度膨胀的风险。

  • 各种Web服务器都“不约而同”地提供了好几个ClassPath路径供用户存放第三方类库,这些路径一般都以"lib"或"classes"命名。

  • 通常,每一个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库

  • Tomcat目录结构中,有3组目录(“/common/“、”/server/“和”/shared/“)可以存放Java类库,另外还可以加上Web应用程序自身的目录”/WEB-INF/”,一共4组,

  • 为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,其关系如图9-1所示

  • 而CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/、/server/、/shared/*和/WebApp/WEB-INF/*中的Java类库

  • 其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器

  • 而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个Class,它出现的目的就是为了被丢弃:当服务器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。

如何做

  • 那么被CommonClassLoader或SharedClassLoader加载的Spring如何访问并不在其加载范围内的用户程序呢

  • 那么被CommonClassLoader或SharedClassLoader加载的Spring如何访问并不在其加载范围内的用户程序呢

9.2.3 字节码生成技术与动态代理的实现

  • 动态代理

  • 它的优势不在于省去了编写代理类那一点工作量,而是实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中。

9.2.4 Retrotranslator:跨越JDK版本

  • 但却可以跨越JDK版本之间的沟壑,把JDK 1.5中编写的代码放到JDK 1.4或1.3的环境中去部署使用

  • 一种名为“Java逆向移植”的工具(Java Backporting Tools)应运而生,Retrotranslator[1]是这类工具中较出色的一个。

9.3 实战:自己动手实现远程执行功能

  • 类似的需求有一个共同的特点,那就是只要在服务中执行一段程序代码,就可以定位或排除问题,但就是偏偏找不到可以让服务器执行临时代码的途径,这时候就会希望Java服务器中也有提供类似Groovy Console的功能。

  • JDK 1.6之后提供了Compiler API,可以动态地编译Java程序

  • 去实现在服务端执行临时代码的功能。

第五部分 高效并发

  • 计算机的运算速度与它的存储和通信子系统速度的差距太大,大量的时间都花费在磁盘I/O、网络通信或者数据库访问上

  • 每秒事务处理数(Transactions Per Second,TPS)是最重要的指标之一,它代表着一秒内服务端平均能响应的请求总数,

12.2 硬件的效率与一致性

  • 基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)

  • 多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),如图12-1所示

12.3 Java内存模型

  • 此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的[3],不会被共享,自然就不会存在竞争问题

  • Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的一部分)。

  • 每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝[4],

  • 线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量[5]。

  • 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,

  • 线程、主内存、工作内存三者的交互关系如图12-2所示。

12.3.2 内存间交互操作

  • 关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的

  • 如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。

  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

  • 等效判断原则——先行发生原则,用来确定一个访问在并发环境下是否安全。

12.3.3 对于volatile型变量的特殊规则

  • volatile

  • 可见性,

  • ,但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的,

  • 使用volatile变量的第二个语义是禁止指令重排序优化,

  • 关键变化在于有volatile修饰的变量,

  • ,这个操作相当于一个内存屏障(

  • 这种操作相当于对Cache中的变量做了一次前面介绍Java内存模式中所说的“store和write”操作[2]。所以通过这样一个空操作,可让前面volatile变量的修改对其他CPU立即可见。

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

  • synchronized块之间的操作也具备原子性。

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

  • 而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把"this"的引用传递出去

  • ,那在其他线程中就能看见final字段的值

  • Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,

12.4 Java与线程

  • 实现线程主要有3种方式:

  • 使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。

  • 轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型,如图12-3所示。

  • 而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。

  • 广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT),因此,从这个定义上来讲,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制。

  • 狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助

  • 使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至不可能完成。因而使用用户线程实现的程序一般都比较复杂[1],除了以前在不支持多线程的操作系统中(如DOS)的多线程程序与少数有特殊需求的程序外,现在使用用户线程的程序越来越少了,Java、Ruby等语言都曾经使用过用户线程,最终又都放弃使用它。

  • 使用用户线程加轻量级进程混合实现

  • 还有一种将内核线程与用户线程一起使用的实现方式。

  • 。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。

  • 并且用户线程的系统调用要通过轻量级线程来完成

  • 在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系

  • 对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的[2]

12.4.2 Java线程调度

  • 主要调度方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。

13.2 线程安全

  • 使用final关键字修饰它就可以保证它是不可变的

  • 保证对象行为不影响自己状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的

13.2.2 线程安全的实现方法

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

  • 在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放

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

  • 如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块(如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。所以synchronized是Java语言中一个重量级(Heavyweight)的操作

  • synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁

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

  • 从处理问题的方式上说,互斥同步属于一种悲观的并发策略

  • 我们有了另外一个选择:基于冲突检测的乐观并发策略

  • 就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。

  • CAS指令需要有3个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)

  • CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。

  • 在JDK 1.5之后,Java程序中才可以使用CAS操作,该操作由sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供

  • 由于Unsafe类不是提供给用户程序调用的类(Unsafe.getUnsafe()的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的Class才能访问它),因此,如果不采用反射手段,我们只能通过其他的Java API来间接使用它,如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作。

  • incrementAndGet()方法在一个无限循环中,不断尝试将一个比当前值大1的新值赋给自己

  • 这个漏洞称为CAS操作的"ABA"问题

  • 如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

  • 其中最重要的一个应用实例就是经典Web交互模型中的“一个请求对应一个服务器线程”

  • 每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

BinBin_Bang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值