java并发编程实践(2)线程安全性

来源:互联网 发布:证件照合成软件 编辑:程序博客网 时间:2024/05/22 02:21
【0】README
0.0)本文部分文字描述转自:“java并发编程实战”, 旨在学习“java并发编程实践(2)线程安全性” 的相关知识;
0.1)几个术语(terms)
t1)对象的状态:是指存储在状态变量中的数据;
t2)共享:意味着变量可以有多个线程同时访问;
t3)可变:意味着变量的值在生命周期内可以发送变化;
Attention)我们将像讨论代码那样来讨论线程安全性,但更侧重于如何防止在数据上发送不受控的并发访问;
0.2)一个对象是否需要是线程安全的,取决于它是否被多个线程访问: 要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问;
0.3)当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问;java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但“同步”术语还包括volatile类型的变量,显式锁以及原子变量;
Conclusion)同步术语有4种: synchronized关键字;volatile类型的变量;显式锁;原子变量;(干货——同步术语有4种)
0.4)如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修改这个问题(ways):
way1)不在线程之间共享该状态变量;
way2)将状态变量修改为不可变的变量;
way3)在访问状态变量时使用同步;

【1】什么是线程安全性
1)在线程安全性的定义中,最核心的概率是正确性;
2)正确性定义:某个类的行为与其规范完全一致;(我们将现场的正确性近似定义为所见即所知)
3)线程安全性定义:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的 ;
4)无状态对象:该对象既不包含任何域,也不包含任何对其他类中域的引用,计算过程中的临时状态仅存储在线程栈上的局部变量中,并且只能由正在执行的线程访问;(干货——无状态对象是线程安全的)

【2】原子性
看个荔枝)计数器
public class UnsafeCountingFactorizer extends GenericServlet implements Servlet {    private long count = 0;    public long getCount() {        return count;    }    public void service(ServletRequest req, ServletResponse resp) {        BigInteger i = extractFromRequest(req);        BigInteger[] factors = factor(i);        ++count; // highlight line.        encodeIntoResponse(resp, factors);    }}
对以上代码的分析(Analysis):
A1)它包含三个独立的操作: 读取count的值;将值加1;然后将计算结果写入count;(干货——这是一个读取——修改——写入的操作序列,并且其结果依赖于以前的状态)
A2)图1.1给出了两个线程在没有同步的case下同时对一个计数器执行递增操作时发生的情况,这不是线程安全的;


【2.1】竞态条件
1)intro:当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件;换句话说,就是正确的结果要取决于运气。最常见的竞态条件类型是“先检查后执行(check-then-act)”操作,即通过一个可能失效的观测结果来决定下一步的动作;(干货——竞态条件它是一个条件,当...的时候,当某个计算的正确性取决于多个线程的交替执行时序时就产生了竞态条件)
2)先检查后执行的概念:竞态条件的本质——基于一种可能失效的观察结果来做出判断或者执行某个计算。这种类型的竞态条件称为“先检查后执行”,首先观察到某个条件为真(如文件X不存在),然后根据这个观察结果采用相应的动作(创建文件X),但事实上,在你观察到这个结果以及开始创建文件之间,观察结果可能变得无效了(另一个线程在这期间创建了文件X),从而导致各种问题(数据被覆盖,文件被破坏等);(干货——先检查后执行的概念)

【2.2】实例:延迟初始化种的竞态条件
1)使用先检查后执行的一种常见case 就是 延迟初始化:延迟初始化的目的是将对象的初始化操作推迟到设计被使用时才进行,同时要确保只被初始化一次;(干货——引入延迟初始化)
看个荔枝)延迟初始化中的竞态条件(不要这么做)
<pre name="code" class="java">public class LazyInitRace {    private ExpensiveObject instance = null;    public ExpensiveObject getInstance() {        if (instance == null)            instance = new ExpensiveObject();        return instance;    }}class ExpensiveObject { }
对以上代码的分析(Analysis):
A1)在LazyInitRace 中包含了一个竞态条件,它可能会破坏这个类的正确性;
A2)假设线程A 和 线程B 同时执行getInstace方法:A看到instance为空, 因此创建一个新的ExpensiveObject 实例;B同样需要判断instance是否为空。此时的instance是否为空,要取决于不可预测的时序,包括线程的调度方式,以及A需要花多长时间来初始化ExpensiveObject 并设置instance;如果B检查到 instance为空, 那么在两次调用getInstance方法时可能会得到不同的结果,即使getInstance通常被认为是返回相同的实例;
Attention)一种竞态条件: 读取——修改——写入这种操作(如count++, 递增一个计数器);

【2.3】复合操作
1)LazyInitRace 类包含一组需要以原子方式执行的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某个方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的 过程中;(干货——如何避免竞态条件问题)
Attention)原子操作定义:假定有两个操作O1 和 O2,如果从执行操作O1 的线程T1来看,当另一个线程T2执行操作O2时,要么将操作O2全部执行完,要么完全不执行操作O2,那么操作O1 和 操作O2 对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说, 这个操作是一个以原子方式执行的操作;(干货——原子操作定义)
2)复合操作:我们将“先检查后修改”以及“读取——修改——写入”等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性;
3)使用一个现有的线程安全类来修改 UnsafeCountingFactorizer 得到 CountingFactorizer 
public class CountingFactorizer extends GenericServlet implements Servlet { // code2.2.3    private final AtomicLong count = new AtomicLong(0); //highlight line. safe thread class.    public long getCount() { return count.get(); }    public void service(ServletRequest req, ServletResponse resp) {        BigInteger i = extractFromRequest(req);        BigInteger[] factors = factor(i);        count.incrementAndGet(); // highlight line.        encodeIntoResponse(resp, factors);    }}
对以上代码的分析(Analysis):
A1)在 java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换;
A2)通过用AtomicLong 来代替long类型的计数器,能够确保所有对计数器状态的访问操作都是原子性的;
A3)由于servlet的状态就是计数器的状态,并且计数器是线程安全的,因此这里的servlet也是线程安全的;
Attention)在实际case中,应该尽可能使用现有的线程安全对象(如AcomicLong)来管理类的状态;

【3】加锁机制(java中用于确保原子性的内置机制)
1)requirement:假设我们想提升servlet的性能,将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因式分解时,可以直接使用上一次的计算结果,而无须重新计算。要实现该缓存策略,需要保存两个状态:最近执行因式分解的数值以及分解结果;
2)代码2.3 通过AtomicLong以线程安全的方式来管理计数器的状态,那么,在这里是否也可以使用类似的 AtomicReference来管理最近执行因式分解的数值及其分解结果吗?
public class UnsafeCachingFactorizer extends GenericServlet implements Servlet {    private final AtomicReference<BigInteger> lastNumber            = new AtomicReference<BigInteger>(); //被分解的数值    private final AtomicReference<BigInteger[]> lastFactors              = new AtomicReference<BigInteger[]>(); //分解后的因子    public void service(ServletRequest req, ServletResponse resp) {        BigInteger i = extractFromRequest(req);        if (i.equals(lastNumber.get()))            encodeIntoResponse(resp, lastFactors.get());        else {            BigInteger[] factors = factor(i);            lastNumber.set(i); //highlight line.            lastFactors.set(factors); //highlight line.            encodeIntoResponse(resp, factors);        }    }     }
对以上代码的分析(非安全的)Analysis:
A1)在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏;
A2)UnsafeCachingFactorizer 的不变性条件之一是:在 lastFactors 中缓存的因数之积应该等于在 lastNumber 中缓存的数值;所以当更新某个变量时,需要在同一个原子操作中对其他变量同时进行更新;如第1次请求分解12, 而第2次请求分解20,第3次请求分解20;当请求分解20的时候,lastNumber变了,这就会引起lastFactors 改变;
A3)在使用原子引用(AtomicReference)的case下,尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumber 和 lastFactors。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破坏了;
A4)而且,我们也不能保证会同时获取两个值:在线程A获取这两个值的过程中,线程B 可能修改了它们,这样线程A 也会发现不变性条件被破坏了;
Attention)要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量;(干货——要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量)

【3.1】内置锁
1)intro to 同步代码块:java提供了一种内置的锁机制来支持原子性——同步代码块;
2)同步代码块分为两部分:一个是作为锁的对象引用,一个是作为由这个锁保护的代码块;
3)以关键字synchronized来修饰的方法就是一种横跨方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁;(干货——同步代码块和锁的定义)
synchronized(lock) {// 访问或修改由锁保护的共享状态}
4)每个java对象都可以用作一个实现同步的锁,这些锁被称为内置锁或监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法;(干货——获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法,且每次只有一个线程执行内置锁保护的代码块)
5)并发环境中的原子性与事务应用程序中的原子性有着相同的含义:一组语句作为一个不可分割的单元被执行;任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块;
6)下面是UnsafeCachingFactorizer 引入同步代码块(synchronized关键字)后的SynchronizedFactorizer 代码:
public class SynchronizedFactorizer extends GenericServlet implements Servlet {    @GuardedBy("this") private BigInteger lastNumber;    @GuardedBy("this") private BigInteger[] lastFactors;    public synchronized void service(ServletRequest req,                                     ServletResponse resp) {        BigInteger i = extractFromRequest(req);        if (i.equals(lastNumber))            encodeIntoResponse(resp, lastFactors);        else {            BigInteger[] factors = factor(i);            lastNumber = i;            lastFactors = factors;            encodeIntoResponse(resp, factors);        }    }
对以上代码的分析(Analysis):
A1)用关键字synchronized来修饰方法service()方法,因此在同一时刻只有一个线程可以执行service方法,这种方法过于极端了,因为多个clients 无法同时使用因式分解,服务的响应性能降低;
A2)所以在 synchronized关键字修改service()方法之后,这就变成一个性能问题,而不是线程安全问题了;(干货——非线程安全转为线程安全但却带来了性能问题)

【3.2】重入(内置锁是可重入的)
1)当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可以重入的,因此如果某个线程试图获取一个已经由它持有的锁,那么这个请求就会成功;
2)重入的概念:“重入”意味着获取锁的操作的粒度是线程,而不是调用;重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。。当计数值为0时,这个锁就被认为是没有被任何线程所持有的。当线程请求一个未被持有的锁时,JVM 将记下锁的持有者,并且将获取计数值设置为1.如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值减为0时,这个锁将被释放;(干货——重入的原理)
3)重入进一步提升了加锁行为的封装性
看个荔枝)子类改写了父类的 synchronized方法,然后调用父类的方法,此时如果没有可重入的锁,那么这段代码将产生死锁;
3.1)产生死锁的原因:因为每个doSth方法在执行前都会获得 Widget上的锁。然而,如果内置锁不是可重入的,那么在调用 super.doSth时将无法获得 Widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获取的锁。重入则避免了这种死锁case的发生;
public class Widget {    public synchronized void doSth(){...}}public class LoggineWidget extends Widget {    public synchronized void doSth() {        super.doSth();    }}
【4】用锁来保护状态
1)状态变量是由这个锁保护的:对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种case下,称状态变量是由这个锁保护的;
2)当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护;因此可以在单个原子操作中访问或更新这些变量,从而确保不变性条件不被破坏;
Attention)对于每个包含多个变量的不变形条件,其中涉及的所有变量都需要由同一个锁来保护;

3)如果同步可以避免竞态条件的问题,为什么不在每个方法声明时都使用关键字synchronized? 事实上,如果不加区别地滥用 synchronized,可能导致程序中出现过多的同步;将每个方法都作为同步方法还可能导致活跃性问题或性能问题;

【5】活跃性与性能
1)参见“3.1”中的SynchronizedFactorizer ,该类的service方法是一个synchronized方法,因此每次只有一个线程可以执行。这就背离了Servlet框架的初衷,即servlet需要能同时处理多个请求,这在负载过高的case下 将给用户带来糟糕的体验;
2)下图给出了当多个请求同时到达 因式分解时发生的case: 这些请求将排队等待处理。我们将这种web应用程序称为“不良并发程序”,因为可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制;

3)幸运的是:通过缩小同步代码块的作用范围,我们很容易做到既确保servlet的并发性,同时又维护线程安全性;应该尽量将不影响共享状态且执行时间过长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态;
4)看个荔枝:将SynchronizedFactorizer修改为 CachedFactorizer,该代码使用两个独立的同步代码块,每个同步代码块都只包含一小段代码。其中一个同步代码块负责保护判断是否只需要返回缓存结果的“先检查后执行”操作序列,另一个同步代码块则负责确保对 缓存的数值和因式分解结果进行同步更新;(干货——同步代码块包括synchronized代码块和synchronized修饰的方法)
public class CachedFactorizer extends GenericServlet implements Servlet {    @GuardedBy("this") private BigInteger lastNumber;    @GuardedBy("this") private BigInteger[] lastFactors;    @GuardedBy("this") private long hits;    @GuardedBy("this") private long cacheHits;    public synchronized long getHits() {        return hits;    }    public synchronized double getCacheHitRatio() {        return (double) cacheHits / (double) hits;    }    public void service(ServletRequest req, ServletResponse resp) {        BigInteger i = extractFromRequest(req);        BigInteger[] factors = null;        synchronized (this) {            ++hits;            if (i.equals(lastNumber)) {                ++cacheHits;                factors = lastFactors.clone();            }        }        if (factors == null) {            factors = factor(i);            synchronized (this) {                lastNumber = i;                lastFactors = factors.clone();            }        }        encodeIntoResponse(resp, factors);    }
Attention)
A1)通常,在简单性与性能之间存在着相互制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)
A2)当执行时间较长的计算或可能无法快速完成的操作时(例如,网络IO或控制台 IO),一定不要持有锁;


0 0
原创粉丝点击