听说戴耳机,效果更佳 ~
ナノウ - なれのはて 来自一个八月想偷懒的开发坑 00:00 05:25
基本
线程安全的核心在于要对状态访问操作进行管理,特别是对共享的和可变的状态的访问;
对象的状态:指存储在状态变量(实例或静态欲)中的数据,可能包含其他依赖对象的域(HashMap的状态不仅存储在HashMap对象本身,还存储在许多Map.Entry对象中),而对象是否安全取决于是否被多个线程访问;
共享状态:变量可以由多个线程同时访问;
可变状态:变量的值在其生命周期中可以发生变化;
安全性
正确性:在并发环境和单线程环境下都不会被破坏的类,单线程的正确性是 “所见即所得”;
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者线程将如何交替执行,并且在主调代码中不需要任何额外的同步或者协同,这个类始终都表现出正确的行为,那么这个类是线程安全的;
大多数Servlet都是无状态的,极大地降低了在实现Servlet线程安全性时的复杂性,只有当Servlet在处理请求时需要保存一些信息才会处理线程安全性问题;
StatelessFactorizer 是无状态的:既不包含任何域,也不包含任何对其他类中域的引用;访问该类的线程不会影响另外一个访问同一个类的线程的计算结果,因此这两个线程没有共享状态,并且不会影响该类的正确性,因此无状态对象一定是线程安全的;
原子性
递增操作 ++count 是一种紧凑的语法,但这个操作并非原子的,因为它并不会作为一个不可分割的操作来执行,它包含三个独立的操作:读取 count 值,将值 +1,将计算结果写入 count,这是一个 “读取-修改-写入”的操作序列,并且其结果状态依赖于之前的状态。在多线程环境下,如果有一次递增操作丢失了,或者在多次调用中读取相同的值,将导致严重的数据完整性问题,或者出现失效数据。
1、竞态条件
当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件,即正确的结果要取决于运气,最常见的竞态条件类型是 “先检查后执行(check-Then-Act)” 操作,即通过一个可能失效的观测结果来决定下一步动作。
大多数竞态条件的本质:基于一种可能失效的观察结果来做出判断或者执行某个计算。“先检查后执行” 的竞态条件类型:首先观察到某个条件为真(例如文件A不存在),然后根据这个观察结果采用相应的动作(创建文件A),但事实上,在观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另外一个线程在这个期间创建文件A)。从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)。
2、延迟初始化中的竞态条件
线程A和线程B同时执行 getInstance方法,A看到 instance 为空,创建一个新的 ExpensiveObject 实例,B 同样判断 instance 为空(取决于不可预测的时序,包括线程的调度方式,以及A需要花多长时间来初始化 ExpensiveObject 并设置 instance),那么在两次调用 getInstance 时可能会取到不同的结果。例如该方法用于初始化应用程序的注册表,在多次调用中返回不同的实例,那么会出现丢失部分注册信息,或者多个行为对同一组注册对象表现出不一致的视图。
3、复合操作
“先检查后执行”、“读取-修改-写入” 等操作统称为复合操作:包含一组必须以原子方式执行的操作以确保线程安全性。
要避免竞态条件问题,必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。
在 java.util.concurrent.atomic 包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换,确保所有对计数器状态的访问操作都是原子的。
加锁机制
尽管这些原子引用本身都是线程安全的,但在该类中存在着竞态条件,在使用原子引用的情况下,尽管对set方法的每次调用都是原子的,但无法同时更新lastNumber和lastFactors,如果只修改其中一个变量,那么在两次修改操作之间,其他线程将发现不变性条件被破坏。当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束,因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。
1、内置锁
Java 提供一种内置的锁机制来支持原子性:同步代码块(Synchronized Block,包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块),以关键字 synchronized 修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象,静态的 synchronized 方法以 Class 对象作为锁。
每个 Java 对象都可以用作一个实现同步的锁,被称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock),线程在进入同步代码块之前会自动获得锁,在退出同步代码块时自动释放锁,获得内置锁的唯一途径是进入由这个锁保护的同步代码块或方法。
Java 内置锁相当于一种互斥锁,只有一个线程能持有该锁,其他线程必须等待或者阻塞,由这个锁保护的代码块会以原子方式执行,多个线程在执行该代码块时互不干扰。
2、重入
内置锁时可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功,重入意味着获取锁的操作的粒度是 “线程” 而不是 “调用”。
重入的一种实现方式是:为每个所关联一个获取计数值和一个所有者线程,JVM 将记下锁的持有者,并且将获取计数值置为1,如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
子类改写父类的 synchronized 方法,然后调用父类中的方法,每个doSomething方法在执行前都会获取Widget上的锁,如果内置锁不可重入,那么在调用 super.doSomething时将无法获得Widget上的锁,因为这个锁已经被持有,从而形成将永远阻塞下去,等待一个永远也无法获得的锁,发生死锁。
用锁保护状态
访问共享状态的复合操作,例如 “读取-修改-写入” 或者 “先检查后执行”,如果在复合操作中持有一个锁那么会成为原子操作,但仅仅将其封装到一个同步代码块中是不够的,如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步,而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁,此时这个状态变量时由这个锁保护的。
一种常见的加锁约定是:将所有的可变状态都封装到对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,是的在该对象不会发生并发访问,在许多线程安全类中都使用这种模式,例如 Vector 和其他同步集合类,在这种情况下对象状态中的所有变量都由对象的内置锁保护起来。
Vector中的每个方法都是同步方法,但并不确保 Vector 上复合操作都是原子的:
"如果不存在则添加"(put-if-absent)的操作仍存在竞态条件,虽然synchronized可以确保单个操作的原子性,但如果要把多个操作合并一个复合操作,还需要额外的加锁机制。
活跃性与性能
通过Servlet对象的内置锁来保护每个状态变量,该策略的实现方式也就是对整个service方法进行同步,将付出很高代价,因为每次只有一个请求可以执行,当多个请求需要处理时将排队等待,此时这种Web应用程序被称为不良并发应用程序:可同时调用的数量不仅受到可用处理资源的限制,还受到其本身结构的限制。
其中一个同步代码块负责保护判断是否只需返回缓存结果的 "先检查后执行" 操作序列,另一个同步代码块负责确保对缓存的数值和因数分解结果进行同步更新,此外 “命中计数器” 和 “缓存命中计数器” 也是共享可变状态的一部分,因此必须在所有访问的位置上都使用同步。位于同步代码块之外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会在多个线程间共享,因此不需要同步。
通常,在简单性与性能之间存在着相互制约因素,当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(可能会破坏安全性)。
参考文献:Java Concurrency in Practice
—— 最好的喜欢,是能够在对方眼里,看到更好的自己。 ——