内置锁-synchronized
Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个保护锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态方法上的synchronized方法以Class作为锁。
每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,无论是通过正常的控制路径退出,还是通过从代码块中抛异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当A线程尝试获取一个由B线程持有的锁时,线程A必须等待或阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远的等下去。
由于每次只能又有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码快会以原子方式执行,多个线程在执行代码块时也不会互相干扰。并发环境中原子性与事务应用程序中的原子性有着相同的含义——一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。
重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。
重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数值相应递减。当计数值为0时,这个锁将被释放。
重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。
用锁来保护状态
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,这种情况下,我们称状态变量是由这个锁保护的。
由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
访问共享状态的复合操作,例如命中计数器递增操作(读取-修改-写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作变为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。
一种常见的错误认为,只有在写入共享变量时才需要使用同步,然而事实并非如此。
对象的内置锁与其状态之间没有内在关联。虽然大多数类都将内置用作一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程获得对象的锁之后,只能阻止其他线程获取同一个锁。之所以每个对象都有一个内置锁,只是为了避免去显式地创建锁对象。这种设计决策或许比较糟糕,不仅会引起混乱,而且还迫使JVM需要在对象大小与加锁性能之间权衡。你需要自行构造加锁协议或同步策略来实现对共享状态的安全访问,并在程序中自始至终的使用它们。
每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象内置锁对所有访问可变状态的代码路径进行同步,使得在对象上不会发生并发访问。在许多线程安全类中都使用了这种模式。例如Vector和其他的同步集合类,在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。如果在添加新的方法或代码路径时忘了使用徒步,那么这种加锁协议会很容易破坏。
当某个变量由锁来保护时,意味着每次访问这个变量时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由用一个锁来保护。因此可以在单个原子操作中访问或更新这些数据,从而确保不变性条件不被破坏。
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由一个锁来保护。
如果不加区别的滥用synchronized,可能导致程序中过多的同步。此外如果只是将每个方法都作为同步方法,例如Vector,那么并不足以确保Vector上复合操作都是原子性的:
if (!vector.contains(element))
vector.add(element);
虽然contains和add等方法都是原子方法,但是上面这个“如果不存在则添加(qut-if-absent)”的操作中仍存在竞态条件。虽然synchronized可以确保单个操作的原子性,但如果要把多个操作合并为一个符合操作,还需要额外的加锁机制。此外,将每个方法都作为同步方法还可能导致活跃性问题(Liveness)或性能问题(Performance)。
活跃性与性能
当系统中有多个CPU,滥用synchronized,会使得当负载很高时,仍然会有处理器处于空闲状态,造成不良并发(Poor Concurrency):可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。
要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这个需求必须得到满足),简单性和性能。有时候,在简单性与性能之间会发生冲突,但在二者之间通常能找到某种合理的平衡。
通常,在简单性与性能之间存在着互相制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)。
当使用锁时你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行可能阻塞的操作,如果持有锁时间过长,那么都会带来活跃性或性能问题。
当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。