likes
comments
collection
share

chapter02 线程安全性

作者站长头像
站长
· 阅读数 3

      在构建稳定的并发程序时,必须正确的使用线程和锁。但这些只是一些机制,要编写线程安全的代码,核心在于要对状态访问操作进行管理,特别是对共享的(shared)和可变的(mutable)状态的访问

      从非正式意义上来说,对象的状态指存储在状态变量(实例或静态域)中的数据,对象的状态可能包括其他依赖对象的域。在对象的状态中包含了任何可能影响其他外部可见行为的数据(可以认为对象的属性就是它的状态)。

      共享意味着变量可以由多个线程同时访问,可变意味着变量的值在其生命周期内可以发生变化。

      一个对象是否需要时线程安全的,取决于他是否被多个线程访问。要使对象是线程安全的,需要采用同步机制来协同对象可变状态的访问,如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。

      当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。java中的主要同步机制是关键字 synchronized,它提供了一种独占加锁的方式,同步还包括volatile 变量、显示锁以及原子变量。

    ** 如果当多个线程访问同一个可变的状态变量时没有合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:**

  • 不在线程之间共享该状态变量。
  • 将状态变量修改为不可变的变量
  • 在访问状态变量时使用同步。

      如果在设计类的时候没有考虑并发访问的情况,那么后续的维护可谓知易行难,如果一开始就设计一个线程安全的类,那么在以后再将这个类修改为线程安全的类要容易的多。

  **    访问某个变量的代码越少,就越容易确保对变量的所有访问都实现正确同步,同时也更容易找出变量在哪些条件下被访问**。程序的封装性越好,就越容易实现程序的线程安全性。

      当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。

      在某些情况中,良好的面向对象设计技术与实际情况的需求并不一致,在这些情况中,可能需要牺牲一些良好的设计原则,以换取性能或者对遗留代码的向后兼容。

1 什么是线程安全性

      在线程安全性的定义中,最核心的概念是正确性。其含义是,某个类的行为与其规范完全一致,在良好的规范中通常会定义各种不变性条件来约束对象的状态,以及定义各种后验条件来描述对象操作的结果。因此,从正确性的角度定义线程安全性:当多线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的

      当多个线程访问某个类时,不管运行环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么称这个类是线程安全的。

在线程安全类中封装了必要的同步机制,因此客户端无需进一步采取同步措施。

一个无状态的Servlet

public class StatelessFactorizer implements Servlet {


    public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        System.out.println("do some thing");
    }

    public void init(ServletConfig servletConfig) throws ServletException {

    }

    public ServletConfig getServletConfig() {
        return null;
    }


    public String getServletInfo() {
        return null;
    }

    public void destroy() {

    }
}

      StatelessFactorizer是无状态的,它不包含任何域也不包含任何其他类中域的引用。计算过程中的临时状态仅存在线程栈上的局部变量中,并且只能由正在执行的线程访问。访问StatelessFactorizer的线程不会影响另一个访问同一个StatelessFactorizer线程的计算结果,因为这两个线程没有共享状态,就好像它们都在访问不同的实例,由于线程访问无状态的行为并不会影响其他线程中操作的正确性。

无状态的对象一定是线程安全的。

2 原子性

public class UnsafeCountingFactorizer implements Servlet {
    
    private long count = 0;
    public void init(ServletConfig servletConfig) throws ServletException {
        count++;
    }
}

      count++操作并非原子的,因为它并不会作为一个不可分割的操作来执行,它包含了三个独立的操作:读取count值,加1,然后将计算结果写入count。多线程在没有同步机制的情况下读取到count值时,可能读取到相同的count值,这个计算就失去准确性。

      在并发编程中,由于不恰当执行时序而出现不正确的结果是一种非常重要的情况,这种情况也被称之为:竞态条件(Race Condition)。

2.1 竞态条件

      当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。

      最常见的竞态条件类型是“先检查后执行”,即通过一个可能失败的观测结果来决定下一步的动作。

2.2 示例:延迟初始化中的竞态条件

public class LazyInitRace {

    private LazyInitRace instance = null;

    public LazyInitRace getInstance(){
        if(instance == null){
            instance = new LazyInitRace();
        }
        return instance;
    }
}

      对于getInstance,就存在竞态条件。A线程和B线程同时访问该方法时,A线程执行new操作没完成,B线程也会进来执行new操作,相当于这个new操作有可能执行2次。

竞态条件并不总是会产生错误,还需要某种不恰当的执行时序。

2.3 复合操作

      要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改过程中。

    假设有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作,这个操作是一个原子的方式执行的操作。

      为了确保线程安全性,先检查后执行 读取-修改-写入等操作必须是原子的,这类型操作统称为复合操作:包含一组必须以原子方式执行的操作以确保线程的安全性。

public class SafeCount {

    private final AtomicInteger integer = new AtomicInteger(0);

    public void c(){
        integer.incrementAndGet();
    }
}

      使用AtomicInteger系列原子变量保证计算操作的原子性,对于SafeCount,它的线程状态取决于integer的线程状态,因此它是一个线程安全类。在一个无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。

    ** 在实际情况中,应尽可能地使用现有的线程安全对象来管理类的状态。与非线程安全的对象相比,判断线程安全对象可能状态及其状态转换情况要更为容易,从而也更容易维护和验证过线程安全性。**

3 加锁机制

      对于包含多个状态的类,即使这些类时原子性的,如果存在不恰当的竞态条件,这个类仍然是线程不安全的。

public class UnSafeCachingFactorizer implements Servlet {

    private final AtomicReference<BigInteger> last = new AtomicReference<BigInteger>();
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();
    
    public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        BigInteger i = (BigInteger) servletRequest;
        if(last.get().equals(i)){
            
        }else {
            last.set(i);
            lastFactors.set(new BigInteger[]{});
        }
    }
}

       对于原子类来说,set操作是原子的,但是对于lastFactors来说,他的set取决于last.get的判断,这种竞态条件是非原子的。

      要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

3.1 内置锁

      java使用内置的锁机制来支持原子性,同步代码块(synchronized block),同步代码块包含两个部分:一个是作为锁的对象引用,一个座位由这个锁保护的代码块,以关键字 synchronized修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象,静态的synchronized方法以Class对象为锁。

synchronized(lock){
    //访问或修改由所保护的共享状态
}

      每个Java对象都可以用做一个实现同步的锁,这个锁被称之为内置锁(intrinsic lock)或监视锁(monitor lock),线程在进入同步代码块之前会自动获取锁,在退出同步代码快后自动释放锁,无论是正常退出还是异常退出,获取内置锁的唯一方式就是进入锁保护的同步代码块或方法。

      Java的内置锁相当于一种互斥体(互斥锁),这意味着最多只有一个线程能持有这种锁,当xianchengA尝试获取一个由B线程持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁,如果B永远不释放锁,那么线程A也将永远等下去。

      由于每次只能由一个线程执行内置锁保护的代码块,因此,这个锁保护的代码块会以原子的方式执行,多个线程在执行该代码块时也不会互相干扰。并发环境中的原子性与实务应用程序中的原子性有相同的含义——一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到其他线程正在执行由同一个锁保护的同步代码块。

public class SynchronizedFactorizer implements Servlet {

    private BigInteger lastNumber;
    
    public synchronized void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        lastNumber = new BigInteger("10");
    }
}

      对于synchronized修饰方法时,线程安全角度考虑这个方法时安全的,但假设方法时间执行过长,其他线程无法执行,性能角度考虑很糟糕。

3.2 重入

      当某个线程请求一个由其他线程持有的锁时,发出的请求就会阻塞,由于内置锁是可以重入的,因此如果某个线程试图获取一个已经由他自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作粒度时线程而不是调用。

public class Widget {

    public synchronized void doSomething(){}
}

class LoggingWidget extends Widget{
    public synchronized void doSomething(){
        super.doSomething();
    }
}

      由于Widget 和 LoggingWidget 的 doSomething方法都是synchronized修饰的,调用doSometing时都会获取Widget的锁,那么在执行super.doSometing时,若没有重入机制就会产生死锁。

4 用锁来保护状态

      由于锁能使其保护的代码路径以串行的形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。

      复合操作都必须是原子操作以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作称为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的,如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步,而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。

      对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。

      对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用作一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护,当获取域对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象锁之后,只能阻止其他线程获取同一个锁,之所以每个对象都有一个内置锁,只是为了免去显示地创建所对象。

      每个共享的和可变的变量都应该只有一个锁来保护,从而使维护人员知道是哪一个锁。

      一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径上进行同步,使得在该对象上不会发生并发访问,例如Vector类。在这种情况下,对象状态中的所有变量都是由对象的内置锁保护起来,但是,如果再添加新的方法或者代码路径时忘了使用同步,那么这种加锁协议会很容易被破坏。

      并非所有的数据都需要保护,只有被多个线程同时访问的可变数据才需要通过锁来保护

      滥用synchronized会造成性能问题,并且在上层调用并不能确保不发生竞态条件。

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

5 活跃性与性能

      synchronized加到方法时,如果方法执行时间过长,其他线程就进入不了这个方法,形成了整体阻塞,对于web应用来说,这将明显降低接口的吞吐量。确定synchronized的范围有利于保证程序的并发性与线程安全性,同步代码快不要过小,并且不要将本应是原子操作拆分到多个同步代码块中,应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而这些操作的执行过程中,其他线程可以访问共享状态。

public class CachedFactorizer implements Servlet {

    private BigInteger lastNumber;

    private BigInteger[] lastFactors;

    private long hits;

    private long cacheHits;

    public synchronized long getHits() {
        return hits;
    }

    public synchronized double getCacheHitRatio() {
        return (double) cacheHits / hits;
    }

    public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        BigInteger i = extractFromRequest(servletRequest);
        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();
            }
        }
    }
}

      synchronized代码块尽量仅维护状态的原子操作。

      要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性、简单性和性能,有时候,咋简单性与性能之间会发生冲突。

  通常,在简单性与性能之间存在着相互制约的因素,当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性。

当执行时间较长的计算或者可能无法快速完成的操作时(例如网络io或控制台io),一定不要持有锁。

总结

chapter02 线程安全性