运行时数据区
程序计数器(Program Counter Register):线程私有内存,指示当前线程所执行字节码的行号,是唯一一个在Java虚拟机规范中没有规定任何异常的区域
JAVA 虚拟机栈(Java Virtual Machine Stacks):线程私有内存,生命周期与线程相同,每个方法在执行时会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口信息,每个方法需要的栈帧大小在编译阶段就完全确定了,运行期间不会动态改变。如果栈深度大于虚拟机允许的最大栈深度,将抛出 StackOverflowError ;如果虚拟机栈动态扩展时无法申请到足够内存,会抛出 OutOfMemoryError
本地方法栈(Native Method Stack):为虚拟机使用到的 Native 方法服务,同时也会抛出 StackOverflowError 和 OutOfMemoryError
JAVA 堆(Java Heap):线程共享区域,所有对象实例以及数组都在堆上分配,Java 堆是垃圾收集器管理的主要区域。由于现代收集器基本都采用分代 GC 算法,所以 Java 堆可以细分为:新生代和老年代,新生代还可以细分为 Eden、From Survivor、To Survivor 三个区域。从内存分配机制来看,线程共享的 Java 堆会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常
方法区(Method Area):线程共享区域,用于存储已被虚拟机加载的类信息、静态变量、常量、即时编译器编译后的代码等数据,这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。当方法区无法满足内存分配需求时,将抛出 OutOfmemoryError 异常
运行时常量池(Runtime Constant Pool):运行时常量池是方法区的一部分。Class 文件中除了有类、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。运行时常量池除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池。另外,运行期间也可能将新的常量放入池中,通过 String#intern 方法
对象分配方式
- 指针碰撞
- 空闲列表
如何解决内存分配的线程安全问题
- 通过对内存分配动作进行同步处理,虚拟机实际上采用 CAS 指令配合失败重试的方式保证更新操作的原子性
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,即线程本地分配缓冲区(Thread Local Allocation Buffer)
对象内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据和对齐填充
简单介绍一下对象头(Header)
- Mark Word:用于存储对象自身的运行时数据,如哈希值、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等,这部分数据在 32 位和 64 位的虚拟机中分别为 32 位和 64 位
- 类型指针:即对象指向它的类型元数据指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,另外,如果是 Java 数组,那在对象头还会有一块用于记录数组长度的数据,因为虚拟机可以通过普通 java 对象的元数据信息确定 java 对象的大小,但是从数组的元数据中却无法确定数组的大小
实例数据:实例数据部分是对象真正存储的有效信息,无论是从父类继承下来的,还是在子类中定义的,都需要记录起来,其中,在父类中定义的变量总会出现在子类之前。从分配策略中可以看出,相同宽度的字段会被分配到一起
对齐填充:HotSpot VM 的自动内存管理系统要求对象起始地址必须是8字节的整数倍,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全
对象的访问定位
如何通过引用操作堆上的具体对象
句柄访问:Java堆中需要划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,优点是在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要改变;缺点是需要另外开辟空间
直接指针:速度快,节省了一次指针定位的时间开销
垃圾收集器
程序计数器、虚拟机栈、本地方法栈随线程而生,随线程而灭,每一个栈帧需要分配的内存大小在类结构确定下来时就已知了,因此不需要考虑回收问题。而Java堆和方法区则不一样,对象和类可以长时间存活,且只有在程序运行期间才能知道会创建哪些对象和类。这部分内存分配和回收都是动态的,垃圾收集器主要关注这部分内存
如何判断一个对象是否存活
- 引用计数:无法解决循环引用问题
- 可达性分析:通过 GC Roots 作为起始点向下搜索,当一个对象到GC Roots没有任何引用链相连接,则证明此对象是不可用的
哪些对象可以作为GC Roots
- 虚拟机栈中的引用
- 方法区中的类静态变量 / 常量
- 本地方法栈中的引用
JAVA引用类型
- 强引用:Object obj = new Object(),垃圾收集器永远不会回收掉被引用的对象
- 软引用(SoftReference):系统将要内存溢出前会把软引用对象列入回收范围进行二次回收
- 弱引用(WeakReference):每次 GC 时,无论当前内存是否足够,都会回收掉弱引用对象
- 虚引用(PhantomReference):无法通过虚引用获取对象实例,在对象被回收时会收到通知
public class PhantomReference<T> extends Reference<T> {
/**
* Returns this reference object's referent. Because the referent of a
* phantom reference is always inaccessible, this method always returns
* <code>null</code>.
*/
public T get() {
return null;
}
}
不可达对象一定会被回收吗
如果可达性分析发现对象没有与 GC Roots 相连接的引用链,会进行筛选,筛选的条件是此对象是否有必要执行 finalize 方法(对象没有覆盖 finalize 方法或者已经执行过),如果有必要执行 finalize 方法,这个对象会被放入 F-Queue 队列中,并由 Finalizer 线程去执行它,但是不保证等待 finalize 执行结束
类在什么情况下会被回收
- 该类的所有实例都已经被回收
- 加载该类的 ClassLoader 已经被回收
- 该类对应的 Class 对象没有被引用
垃圾收集算法
- 标记清除算法(Mark-Sweep):会产生内存碎片,效率没有复制算法高
- 复制算法(Copying):如果不采用内存担保,浪费一半内存
- 标记整理算法(Mark-Compact):标记过程和标记清除一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
- 分代收集算法(Generational Collection):根据对象存活周期不同将内存分为几块,一般为新生代和老年代,针对各个年代的特点,选用不同的收集算法
安全点:“是否具有让程序长时间执行的特征”为标准选定的,因为每条指令的执行时间都非常短,程序不太可能因为指令流长度而过长时间执行,长时间执行的最明显特征就是指令序列重复,例如方法调用、循环跳转等,所以具有这些功能的指令才会产生 Safepoint
抢先式中断 vs (主动式中断:各个线程执行时轮训中断标志,发现中断标志为真就自己中断挂起,轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方)
安全区域:指在一段代码片段之中,引用关系不会发生变化,在这个区域中的任意地方开始 GC 都是安全的。在线程执行到安全区中的代码时,会标识自己进入了安全区,当 JVM 发起 GC 时,就不用管 Safe Region 状态的线程了,在线程要离开 Safe Region 时,它要检查系统是否已经完成了根节点枚举,如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开 Safe Region 的信号为止
垃圾收集器
- Serial收集器:单线程、Stop The World、新生代收集器
- ParNew收集器:多线程、新生代收集器
- Parallel Scavenge收集器:复制算法、新生代收集器、吞吐量控制
- Serial Old收集器:单线程、标记整理、老年代
- Parallel Old收集器:多线程、标记整理、老年代
- CMS(Concurrent Mask Sweep):无法处理浮动垃圾、内存碎片化(Compact可控参数)
- 初始标记
- 并发标记
- 重新标记(修正并发标记期间因用户程序继续运行而导致标记产生变动)
- 并发清除
- G1收集器(并行与并发、分代收集、空间整合、可预测的停顿时间),G1 收集器使用Remembered Set 来避免全堆扫描
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
内存分配与回收策略
对象优先在 Eden 区分配
大多数情况下,对象在新生代 Eden 区中分配,当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC,如果回收后的对象无法全部放入 To Survivor 空间,则通过老年代进行担保
大对象直接进入老年代(-XX:PretenureSizeThreshold)
避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制
长时间存活的对象将进入老年代
新生代对象每经过一次 Minor GC 后仍然存活,对象年龄加1
动态对象年龄判定
为了更好地适应不同程序的内存状况,虚拟机并不是永远要求对象的年龄必须达到MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
空间分配担保
在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看设置是否允许担保失败,如果允许,会继续检查老年代最大可用空间是否大于历次晋升到老年代对象的平均大小,如果大于,则尝试进行一次 Minor GC,如果小于,或设置不允许冒险,则改为 Full GC
Class 文件结构
无关性:ByteCode 实现了平台无关性和语言无关性,Java 虚拟机不和包括 Java 在内的任何语言绑定,它只与 Class 文件所关联
整个 Class 文件本质上就是一张表,具体数据项如下:
常量池:主要存放两大类常量,字面量和符号引用,字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。也就是说,在 Class 文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中
常量池中每一项常量都是一个表,这14种表都有一个共同的特点,就是表开始的第一位是一个u1类型的标志位,代表当前这个常量属于哪种常量类型
访问标志:用于识别一些类或者接口的访问信息
类索引、父类索引、接口索引:指向常量池的索引,用于确定类和接口的名称
字段表集合:用于描述接口或者类中声明的变量
方法表集合:
方法的定义通过访问标志、名称索引、描述符索引,具体代码会存储在 attributes 名称为 Code 的属性中
属性表集合
在 Class 文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表(attribute_info)集合不再要求各个属性表具有严格顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java 虚拟机会忽略掉它不认识的属性。为了能正确解析 Class 文件,Java 虚拟机规范中预定义了21项,常见的属性名称有 Code、ConstantValue、Exceptions、Deprecated、LineNumberTable、LocalVariableTable等
Code 属性
max_stack 代表了操作数栈深度的最大值,在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行时需要根据这个值来分配栈帧中的操作数栈深度
max_locals 代表了局部变量表所需的存储空间,方法参数(包括实例方法中的隐藏参数“this”)、显示异常处理器的参数(就是 try-catch 语句中 catch 块所定义的异常)、方法体中定义的局部变量都需要使用局部变量表来存储。另外,并不是在方法中用到了多少个局部变量,就把这些局部变量所占 Slot 之和作为 max locals 的值,原因是局部变量表中的 Slot 可以重用
exception_table格式如下,用于表示代码中的异常处理逻辑
Exceptions属性
该属性用于列举方法中可能抛出的受查异常(Checked Exceptions),也就是方法描述时在throws关键字后面列举的异常
ConstantValue 属性
该属性的作用是通知虚拟机自动为静态变量赋值。只有被 static 关键字修饰的变量才可以使用这项属性。其中,对于非 static 类型的变量的赋值是在实例构造器 <init> 方法中进行的;而对于类变量,则有两种方式可以选择:在类构造器 <clinit> 方法中或者使用 ConstantValue 属性。目前 Sun Javac 编译器的选择是:如果同时使用 final 和 static 来修饰一个变量,并且这个变量的数据类型是基本类型或者 String 的话,就生成 ConstantValue 属性来进行初始化,如果变量没有被 final 修饰,或者非基本类型及字符串,则将会选择在 <clinit> 方法中进行初始化。对于 ConstantValue 的属性值只能限于基本类型和 String,大概率是因为此属性的属性值是指向常量池的索引号,由于Class 文件格式的常量类型中只有与基本类型和字符串对应的字面量,所以就算 ConstantValue 属性想支持别的类型也无能为力
虚拟机类加载机制
虚拟机把 Class 文件加载到内存,并对数据进行验证、准备、解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制
类的生命周期
加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序开始,而解析阶段则不一定:它在某些情况下可以在初始化之后再开始,这是为了支持 Java 语言的运行时绑定
虚拟机严格规定了有且只有 4 种情况必须立即对类进行初始化
- 遇到 new、getstatic、putstatic、invokestatic 这4条字节码指令时
- 使用 java.lang.reflect 包的方法对类进行反射调用的时候
- 当初始化一个类的时候,如果发现其父类还没有初始化,则需要先触发父类的初始化
- 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类
通过子类来引用父类的静态字段,只会初始化父类
创建对象数组,不会触发类初始化
编译阶段通过常量传播优化,已经将此常量的值存储到了 NotInitialization 类的常量池中
类加载过程之加载
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成对应的 java.lang.Class 对象,作为方法区这个类的数据访问入口
类加载过程之验证
验证阶段主要是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,主要流程有:
- 文件格式验证:魔术、版本、常量池是否有不支持的类型、指向常量池的索引是否有异常,UTF8类型的常量编码信息是否正确等
- 元数据验证:是否有父类、是否继承了不允许继承的类(final)、是否实现了抽象方法、字段是否冲突等
- 字节码验证:跳转指令、操作数栈是否与指令代码序列匹配、类型转换是否正确
- 符号引用验证:符号引用中的类、字段、方法是否存在
类加载过程之准备
准备阶段是正式为类变量分配内存并设置类变量初始值(零值)的阶段,这些变量所使用的内存都将在方法区中进行分配
类加载过程之解析
解析阶段是将常量池内的符号引用替换为直接引用的过程,符号引用在分析 Class 类结构时多次出现
- 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可能各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中
- 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量。直接引用是和虚拟机实现的内存布局相关的
虚拟机规范中并未规定解析阶段发生的具体时间,只要求了在执行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、new、putfield、putstatic这些操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic和invokeinterface指令(动态解析)以外,虚拟机实现可以对第一次解析的结果进行缓存从而避免解析动作重复进行
解析过程中可能会出现的异常:IllegalAccessError、NoSuchFieldError等
类加载过程之初始化
初始化阶段是执行类构造器<clinit>方法的过程,clinit方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,顺序是由语句在源文件中出现的顺序所决定
虚拟机会保证一个类的clinit方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的clinit方法,其他线程都需要阻塞
类加载器
比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不想等
双亲委派模型:子类在加载类时首先委托父类去加载,如果父类加载失败,在调用自身的加载逻辑
虚拟机字节码执行引擎
每一个栈帧都包括了局部变量表、操作数栈、动态链接、方法返回地址,在编译阶段,栈帧中需要多大的局部变量表、多深的操作数栈都已经确定了,并写入到方法表的Code属性中,在栈帧中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法
局部变量表:局部变量表是GC Roots之一,如果变量不置为空,引用的对象无法回收
操作数栈:在概念模型中,两个栈帧作为虚拟机栈的元素,是完全互相独立的。但在大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠,这样在进行方法调用时就可以共用一部分数据,而无序进行额外的参数复制传递
动态链接:每个帧栈都包含一个指向运行时常量池中该帧栈所属方法的引用,用于支持方法调用过程中的动态连接(意思就是动态决定执行哪个重写方法)
方法返回地址
方法调用
所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的版本,并且这个方法的调用版本在运行期是不可改变的。在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下:invokestatic(调用静态方法)、invokespecial(构造器、私有方法、父类方法)、invokevirtual(虚方法)、invokeinterface、invokedynamic
静态分派
Human man = new Man(),我们把 Human 称为变量的静态类型,Man 则称为变量的实际类型,所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分配的动作实际上不是由虚拟机来执行的
动态分派
invokevirtual 指令的运行时解析过程:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做 C
- 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回IllegalAccessError
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程
- 如果使用没有找到合适的方法,则抛出AbstractMethodError
虚拟机动态分配实现
由于动态分配是非常频繁的动作,大部分虚拟机实现都不会真正地进行如此频繁的搜索,最常用的稳定优化手段就是为类在方法区中建立一个虚方法表(Virtual Method Table),于此对应的,在invokeinterface 执行时也会用到接口方法表(Interface Method Table)
JAVA内存模型
为了解决存储设备与处理器的速度差异,现代计算机都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。但是引入了一个新的问题:缓存一致性问题(Cache Coherence),除了增加高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order-Execution)优化,对应Java虚拟机的即使编译器中也有类似的指令重排序优化(Instruction Reorder)
Java 内存模型
Java 虚拟机规范中试图定义一种 Java 内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,已实现让 Java 程序在各个平台下都能达到一致的内存访问效果。定义 Java 内存模型并非一件容易的事,这个模型必须定义的足够严谨,才能让 Java 的并发内存访问操作不会产生歧义,但是,也必须定义的足够宽松,使得虚拟机的实现有足够的自由空间去利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更高的执行速度
主内存与工作内存
Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中变量
volatile:可见性、有序性,通过内存屏障指令实现(lock),典型应用场景(double check)
volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立即反应到其他线程中(每次使用之前都要先刷新)
普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致
Java内存模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性,到那时对于64位的数据类型,在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的读写原子性。如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改的值。不过商用虚拟机基本都实现了原子操作
原子性、可见性与有序性
基本数据类型的访问读写是具备原子性的
volatile 可以保证可见性和有序性、synchronized 可以保证可见性和原子性
JAVA线程实现
实现线程主要有3种方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现
使用内核线程实现
内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换。程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口,轻量级进程(Light Weigh Process,LWP)。由于内核线程的支持,每个轻量级进程都称为一个独立的调度单元,但是轻量级进程具有它的局限性:首先,由于基于内核线程实现的,所以各种线程操作(创建、同步)都需要进行系统调用,而系统调用的代价相对较高,需要切换内核态和用户态
使用用户线程实现
用户线程(User Thread,UT)的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助,不过实现复杂,很难处理阻塞、处理器分配这类问题,最终Java、Ruby等语言都弃用了这种方案
用户线程加轻量级进程的混合实现
在这种实现下,即存在用户线程,也存在轻量级进程。用户线程完全建立在用户空间中,因此用户线程的创建、切换等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能和处理器映射
JAVA线程的实现
虚拟机规范中并未限定Java线程需要使用那种线程模型来实现,对于 Sun JDK 来说,它的 Windows 版和 Linux 版都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程之中
JAVA线程的调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种:协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling),由于协同式调度会导致恶意竞争,目前主流实现方式是抢占式调度。对于抢占式调度,每个线程由系统来分配时间片,对应可以设置线程优先级
线程状态
- 新建(New)
- 运行(Running、Ready)
- 无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,需要被其他线程显示的唤醒:Object.wait、Thread.join
- 限期等待(Time Waiting):处于这种状态的线程不会被分配CPU执行时间,不过无须等待被其他线程唤醒,在一定时间内它们会由系统自动唤醒:Thread.sleep、设置了Timeout参数的Object.wait和Thread.join
- 阻塞(Blocked):阻塞状态和等待状态的区别:阻塞状态在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而等待状态则是等待一段时间,或者唤醒动作的发生。在程序等待进入同步区时,线程将进入这种状态
- 结束(Terminated)
线程安全
当多个线程访问一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协作操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的
不可变
只要一个不可变的对象被正确构建出来,那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程之中处于不一致的状态
绝对线程安全
java.util.Vector 是一个线程安全的容器,因为它的 add、get、remove、size 这类方法都被 synchronized 修饰,但是也不是绝对安全的,获取 size 之后,如果执行 remove 可能会存在ArrayIndexOutOfBoundsException 的情况
相对线程安全
需要保证对这个对象单独的操作时线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性
线程安全的实现方式
互斥同步
在 Java 中,最基本的互斥同步手段就是 synchronized 关键字,synchronized 关键字经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,对应需要一个 reference 类型的参数来指明要锁定和解锁的对象。在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行 monitorexit 指令时会将锁的计数器减1,当计数器为0时,锁被释放。synchronized 同步块对同一个线程来说是可重入的,不会出现自己把自己锁死的问题
除了 synchronized 之外,还可以使用 ReentrantLock 来实现同步,相比 synchronized,ReentrantLock 增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁、锁可以绑定多个条件(多次调用 newCondition 即可)
非阻塞同步
同步互斥最主要的问题就是进行线程阻塞和环境所带来的性能问题,因为这种同步也称为阻塞同步。无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态转换、维护锁计数器和检查是否被阻塞的线程需要唤醒等操作。随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)
为什么需要硬件指令集的发展呢,因为我们需要操作和冲突检测这两个步骤具备原子性,如果这里再使用互斥同步来保证就失去意义了,所以我们只能靠硬件来完成,这类指令常用的有:
- 测试并设置(Test-and-Set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap):比如 AtomicBoolean
CAS 指令需要三个操作数,分别是内存位置 V、旧的预期值 A、新值 B,CAS指令执行时,当且仅当 V 符合旧预期值 A 时,处理器用新值 B 更新 V 的值
线程本地存储(Thread Local Storage)
Java中对应 ThreadLocal 类
锁优化
自旋锁与自适应自旋
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程都需要转入内核态中完成,同时共享数据的锁定状态只会持续很短的时间,为了让线程等待,我们只需让线程执行一个忙循环,这项技术就是所谓的自旋锁
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。典型的例子:
每个 StringBuffer.append 方法中都有一个同步块,锁就是 sb 对象
锁粗化
防止一段逻辑反复加锁和解锁
轻量级锁
在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位01状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 拷贝,然后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位将转变为00,即表示此对象处于轻量级锁状态
如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为10,Mark Word 中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态
轻量级锁能提升程序同步性能的一句是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销。如果存在锁竞争,轻量级锁出了互斥量的开销外,还额外发生了 CAS 操作,会比传统的重量级锁更慢
偏向锁
如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不需要了。当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标记位设为01,即偏向模式,同时使用CAS操作把获取到这个锁的线程ID记录在对象头的Mark Word中