锁 | volatile | CAS | final | static | |
---|---|---|---|---|---|
原子性保障 | 具备 | 具备 | 具备 | 不涉及 | 不涉及 |
可见性保障 | 具备 | 具备 | 不具备 | 不具备 | 具备① |
有序性保证 | 具备 | 具备 | 不涉及 | 具备 | 具备② |
上下文切换 | 可能 | 具备 | 不会 | 不会 | 可能③ |
备注 | 被征用的锁可能导致上下文切换 | 仅能够保障对volatile变量的读/写操作本身的原子性 | ①②仅在一个线程初次读取一个类的静态变量时起作用 ③静态变量所属类的初始化可能导致上下文切换 |
锁
锁是Java平台中功能最强大的一种线程同步机制,同时其开销也最大,可能导致的问题也最多。被争用的锁会导致上下文切换,锁还可能导致死锁、锁死等线程活性故障。锁适用于存在多个线程对多个共享数据进行更新、check-then-act操作或者read-modify-write操作这样的场景。
锁的排他性以及Java 虚拟机在临界区前后插入的内存屏障使得临界区中的操作具有原子性。由此,锁还保障了写线程在临界区中执行操作在读线程看来是有序的,即保障了有序性。Java 虚拟机在 MonitorExit对应的机器码后插入的内存屏障则保障了可见性。锁能够保障线程安全的前提是访问同一组共享数据的多个线程必须同步在同一个锁之上,否则原子性、可见性和有序性均无法得以保障。在满足貌似串行语义的前提下,临界区内以及临界区外的操作可以在各自范围内重排序。临界区外的操作可能会被JIT编译器重排到临界区内、但是临界区内的操作不会被编译器、处理器重排到临界区之外。
Java中的所有锁都是可重入的。内部锁( synchronized )仅支持非公平锁,因此它可能导致饥饿。而显式锁(ReentrantLock )既支持非公平锁又支持公平锁,显式锁可能导致锁泄漏。内部锁和显式锁各有所长,各有所短。读写锁(ReadWriteLock )由于其内部实现的复杂性,仅适用于只读操作比更新操作要频繁得多且读线程持有锁的时间比较长的场景。读写锁( ReadWriteLock )中的读锁和写锁是一个锁实例所充当的两个角色,并不是两个独立的锁。
线程转储中可以包含锁的相关信息——线程在等待哪些锁,这些锁又是被哪些线程持有的。
volatile相当于轻量级锁。在线程安全保障方面与锁相同的是,volatile能够保障可见性、有序性;与锁不同的是 volatile不具有排他性,也不会导致上下文切换。与锁类似,Java虚拟机实现volatile对有序性和可见性的保障也是借助于内存屏障。从这个角度来看,volatile变量写操作相当于释放锁,volatile变量读操作相当于获得锁--—-Java虚拟机通过在volatile变量写操作之前插入一个释放屏障,在volatile变量读操作之后插人一个获取屏障这种成对的释放屏障和获取屏障的使用实现了volatile对有序性的保障。类似地,Java虚拟机在volatile变量写操作之后插入一个存储屏障,在volatilc变量读操作之前插人一个加载屏障这种成对的存储屏障与加载屏障的使用实现了volatile对可见性的保障。
在原子性方面,volatile仅能够保障long/double型变量写操作的原子性。如果要保障对volatile变量的赋值操作的线程安全,那么赋值操作右边的表达式不能涉及任何共享变量(包括被赋值的变量本身)。volatile关键字在可见性、有序性和原子性方面的保障并不会对其修饰的数组的数组元素的读、写操作起作用。
volatile变量写操作的成本介于普通变量的写操作和在临界区内进行的写操作之间。读取一个volatile变量总是意味着(通过高速缓存进行的)读内存操作,而不是从寄存器中读取。因此,volatile变量读操作的成本比读取普通变量要略高一些,但比在临界区中读取变量要低。
volatile的典型运用场景包括:一,使用volatile变量作为状态标志;二,使用volatile保障可见性;三,使用volatile变量替代锁;四,使用volatile实现简易版读写锁。
CAS使得我们可以在不借助锁的情况下保障read-modify-write操作、check-then-act操作的原子性,但是它并不保障可见性,原子变量类相当于基于CAS实现的增强型volatile变量(保障volatile无法保障的那一部分操作的原子性)。常用的原子变量类包括AtomicInteger、AtomicLong、AtomicBoolean等。AtomicStampedReference则可以用于规避CAS的ABA问题。
static关键字能够保证一个线程即使在未使用其他同步机制的情况下也总是可以读取到一个类的静态变量的初始值(而不是默认值)。对于引用型静态变量,static还确保了该变量引用的对象已经初始化完毕。但是,static的这种可见性和有序性保障仅在一个线程初次读取静态变量的时候起作用。
final关键字在多线程环境下也有其特殊作用:当一个对象被发布到其他线程的时候,该对象的所有final字段(实例变量)都是初始化完毕的。而非final字段没有这种保障,即这些线程读取该对象的非final字段时所读取到的值可能仍然是相应字段的默认值。对于引用型final字段,final关键字还进一步确保该字段所引用的对象已经初始化完毕。
实现对象的安全发布,通常可以依照以下顺序选择适用且开销最小的线程同步机制。·使用static关键字修饰引用该对象的变量。
·使用final关键字修饰引用该对象的变量。使用volatile关键字修饰引用该对象的变量。·使用AtomicReference来引用该对象。
·对访问该对象的代码进行加锁。
为避免将this代表的当前对象逸出到其他线程,我们应该避免在构造器中启动工作者线程。通常我们可以定义一个init方法,在该方法中启动工作者线程。在此基础上,定义一个工厂方法来创建(并返回)相应的实例,并在该方法中调用该实例的init方法。
Object.wait()/notify()的内部实现
我们知道Java虚拟机会为每个对象维护一个入口集(Entry Set)用于存储中请该对象内部锁的线程。此外,Java虚拟机还会为每个对象维护一个被称为等待集(Wait Set )的队列,该队列用于存储该对象上的等待线程。Object.wait()将当前线程暂停并释放相应内部锁的同时会将当前线程(的引用)存入该方法所属对象的等待集中。执行一个对象的notify方法会使该对象的等待集中的一个任意线程被唤醒。被唤醒的线程仍然会停留在相应对象的等待集之中,直到该线程再次持有相应内部锁的时候(此时Object.wait()调用尚未返回) Object.wait()会使当前线程从其所在的等待集移除,接着Object.wait()调用就返回了。Object.wait()/notify()实现的等待/通知中的几个关键动作,包括将当前线程加入等待集、暂停当前线程、释放锁以及将唤醒后的等待线程从等待集中移除等,都是在Object.wait()中实现的。Object.wait()的部分内部实现相当于如下伪代码:
等待线程在语句①被执行之后就被暂停了。被唤醒的线程在其占用处理器继续运行的时候会继续执行其暂停前调用的Object.wait()中的其他指令,即从上述代码中的语句②开始继续执行:先再次申请Object.wait()所属对象的内部锁,接着将当前线程从相应的等待集中移除,然后Object.wait()调用才返回!
Java运行时存储空间
了解Java运行时存储空间的有关知识有助于我们更好地理解多线程编程。Java运行时( Java Runtime)空间可以分为堆(Heap)空间、栈( Stack)空间和非堆(Non-Heap )空间。其中,堆空间和非堆空间是可以被多个线程共享的,而栈空间则是线程的私有空间,每个线程都有其栈空间,并且一个线程无法访问其他线程的栈空间。
堆空间( Heap space)用于存储对象,即创建一个实例的时候该实例所需的存储空间是在堆空间中进行分配的,堆空间本身是在Java虚拟机启动的时候分配的一段可以动态扩容的内存空间。因此,类的实例变量是存储在堆空间中的。由于堆空间是线程之间的共享空间、因此实例变量以及引用型实例变量所引用的对象是可以被多个线程共享的。不管引用对象的变量的作用域如何(局部变量、实例变量和静态变量),对象本身总是存储在堆空间中的。堆空间也是垃圾回收器( Garbage Collector)工作的场所.即堆空间中没有可达引用的对象(不再被使用的对象)所占用的存储空间会被垃圾回收器回收。堆空间通常可以进一步划分为年轻代( Young Generation)和年老代( Old/Tenured Generation )。对象所需的存储空间是在年轻代中进行分配的。垃圾回收器对年轻代中的对象进行的垃圾回收被称为次要回收(Minor Collection )。次要回收中“幸存”下来(即没有被回收掉)的对象最终可能被移人(改变对象所在的存储空间)年老代。垃圾回收器对年老代中的对象进行的垃圾回收被称为主要回收( Major Collection ).
栈空间(Stack Space)是为线程的执行而准备的一段固定大小的内存空间,每个线程都有其栈空间'。栈空间是在线程创建的时候分配的。线程执行(调用)一个方法前,Java虚拟机会在该线程的栈空间中为这个方法调用创建一个栈帧( Frame )。栈帧用于存储相应方法的局部变量、返回值等私有数据。可见,局部变量的变量值存储在栈空间中。基础类型( Primitive Type)变量和引用类型(Reference Type)变量的变量值都是直接存储在栈帧中的*。引用型变量的值相当于被引用对象的内存地址,而引用型变量所引用的对象仍然在堆空间中。也就是说,对于引用型局部变量,栈帧中存储的是相应对象的内存地址而不是对象本身!由于一个线程无法访问另外一个线程的栈空间,因此,线程对局部变量以及对只能通过当前线程的局部变量才能访问到的对象进行的操作具有固有( Inherent〉的线程安全性。
非堆空间( Non-Heap Space)用于存储常量以及类的元数据(Meta-data)等,它也是在Java虚拟机启动的时候分配的一段可以动态扩容的内存空间。类的元数据包括类的静态变量、类有哪些方法以及这些方法的元数据(包括名称、参数和返回值等)。非堆空间也是多个线程之间共享的存储空间。类的静态变量在非堆空间中的存储方式与局部变量在栈空间的存储方式相似,即这些空间中仅存储变量的值本身,而引用型变量所引用的对象仍然存储在堆空间中。
例如,Java 虚拟机运行如清单6-1所示的程序所涉及的运行时空间如图6-1所示(图中箭头表示引用型变量对相应对象的引用关系).