2.线程安全性

线程安全性

要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的和可变的状态的访问。
对象的状态可能包括其他依赖对象的域,共享则意味着变量可以由多个线程同时访问,而可变则意味着变量的值在其生命周期内可以发生变化。

一个对象是否需要是线程安全的,取决于它是否被多个线程访问。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但同步这个术语还包括volatile类型的变量,显式锁以及原子变量。

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

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

程序状态的封装性越好,就越容易实现程序的线程安全性。当设计线程安全的类时,良好的面向对象技术、不可修改性,以及清晰的不变性规范都能起到一定的帮助作用。
在某些情况中,良好的面向对象设计技术与实际情况的需求并不一致。在这些情况中,可能需要牺牲一些良好的设计原则,以换取性能或者对遗留代码的向后兼容。有时候,面向对象中的抽象和封装会降低程序的性能,但在编写并发应用程序时,一种正确的编程方法就是:首先使代码正确运行,然后再提高代码的速度。即便如此,最好也只是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境中确实能带来性能提升时,才进行优化。

2.1什么是线程安全性

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

在当前很多主流的应用框架中,例如servlet,其都能创建多个线程并在这些线程中调用自己编写的代码,因此需要保证编写的代码是线程安全的。例如,简单的因数分解servlet:

public class _1StatelessFactorizer extends HttpServlet {
    @Override
    public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(res, factors);
    }

    void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        // Doesn't really factor
        return new BigInteger[] { i };
    }
}

与大多数Servlet相同,StatelessFactorizer无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。
无状态对象一定是线程安全的。

2.2原子性

当在无状态对象中增加一个状态时,例如增加一个命中计数器来统计所处理的请求数量:

@NotThreadSafe
public class UnsafeCountingFactorizer extends HttpServlet {
    private long count = 0;

    public long getCount() {
        return count;
    }

    @Override
    public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        ++count;
        encodeIntoResponse(res, factors);
    }

    void encodeIntoResponse(ServletResponse res, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        // Doesn't really factor
        return new BigInteger[] { i };
    }
}

虽然递增操作是一种紧凑的语法,使其看上去只是一个操作,但这个操作并非原子的。实际上,它包含了三个独立的操作:读取值,将值加1,然后将计算结果写入。这是一个读取-修改-写入的操作序列,并且其结果状态依赖于之前的状态。

2.2.1竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确的结果要取决于运气。最常见的竞态条件类型就是先检查后执行操作,即通过一个可能失效的观测结果来决定下一步的动作:首先观察到某个条件为真(例如文件X不存在),然后根据这个观察结果采用相应的动作(创建文件X),但事实上,在观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另一个线程在这期间创建了文件X),从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)。

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

使用先检查后执行的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次:、

@NotThreadSafe
public class LazyInitRace {
    private ExpensiveObject instance = null;

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

        return instance;
    }
}

LazyInitRace中包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程A和线程B同时执行getInstance。A看到instance为空,因而创建了一个新的ExpensiveObject实例。B同样需要判断instance是否为空。此时的instance是否为空,要取决于不可预测的时序,包括线程的调度方式,以及A需要花多长时间来初始化ExpensiveObject并设置instance。如果当B检查时,instance为空,那么在两次调用getInstance时可能会得到不同的结果,即使getInstance通常被认为是返回相同的实例。
与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行时序,然而,竞态条件也可能导致严重的问题。

2.2.3符合操作

要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。
为了确保线程安全性,先检查后执行(例如延迟初始化)和读取-修改-写入(例如递增运算符)等操作必须是原子的。统一将这些操作称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。就目前而言,先采用另一种方式来修复这个问题,即使用一个现有的线程安全类:

public class CountingFactorizer extends HttpServlet {
    private final AtomicLong count = new AtomicLong(0);

    public long getCount() {
        return count.get();
    }

    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count.incrementAndGet();
        encodeIntoResponse(resp, factors);
    }
}

通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。由于Servlet的状态就是计数器的状态,并且计数器是线程安全的,因此这里的Servlet也是线程安全的。

2.3加锁机制

假设希望提升Servlet的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无须重新计算。要实现该缓存策略,需要保存两个状态:最近执行因数分解的数值,以及分解结果:

@NotThreadSafe
public class UnsafeCachingFactorizer extends HttpServlet {
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();

    @Override
    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);
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }

    void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        // Doesn't really factor
        return new BigInteger[]{i};
    }
}

然而,这种方法并不正确。尽管这些原子引用本身都是线程安全的,但在UnsafeCachingFactorizer中存在着竞态条件,这可能产生错误的结果。
在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。UnsafeCachingFactorizer的不变性条件之一是:在lastFactors中缓存的因数之积应该等于在lastNumber中缓存的数值。当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。
在使用原子引用的情况下,尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumberlastFactors。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破坏了。同样,也不能保证会同时获取两个值:在线程A获取这两个值的过程中,线程B可能修改了它们。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

2.3.1内置锁

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

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

每个java对象都可以用作一个实现同步的锁,这些锁被称为内置锁或监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java的内置锁相当于一种互斥锁,即最多只有一个线程能持有这种锁。由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子的方式执行。
这种同步机制使得要确保因数分解Servlet的线程安全性变得更简单:

@ThreadSafe
public class SynchronizedFactorizer extends HttpServlet {
    @GuardedBy("this")
    private BigInteger lastNumber;
    @GuardedBy("this")
    private BigInteger[] lastFactors;

    @Override
    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);
        }
    }
}

然而,这种方法却过于极端,因为多个客户端无法同时访问该Servlet,服务的响应性非常低。这是一个性能问题,而不是线程安全问题。

2.3.2重入

当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此,如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。
重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。

public class Widget {
	public synchronized void doSomething() {
		// ...
	}
}

public class LoggingWidget extends Widget {
	public synchronized void doSomething() {
		System.out.println(toString() + ": calling doSomething");
		super.doSomething();
	}
}

如果没有可重入的锁,那么这段代码将产生死锁。

2.4用锁来保护状态

如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。
一种常见的错误是认为,只有在写入共享变量时才需要使用同步,然而事实并非如此。

对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用作一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。开发者需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中自始至终地使用它们。
每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全类中都使用了这种模式,例如Vector。然而,如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议很容易被破坏。
并非所有的数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。

当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。不过需要注意的是,如果不加区别地滥用synchronized,可能导致程序中出现过多的同步。
另一方面,如果只是将每个方法都作为同步方法,例如Vector,那么并不足以确保Vector上复合操作都是原子的:

if (!vector.contains(element)) {
	vector.add(element);
}

虽然synchronized方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还是需要额外的加锁机制。此外,将每个方法都作为同步方法还可能导致活跃性问题或性能问题。

2.5活跃性与性能

SynchronizedFactorizer类中,由于service是一个synchronized方法,因此每次只有一个线程可以执行。这就背离了servlet框架的初衷,即servlet需要能同时处理多个请求。
幸运的是,通过缩小同步代码块的作用范围,很容易做到既确保servlet的并发性,同时又维护线程的安全性。要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。

@ThreadSafe
public class CachedFactorizer extends HttpServlet {
	// 由于引入了同步代码块,因此不再需要原子工具类
    @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;
    }

	/**
	 * 修改为使用两个独立的同步代码块,其中一个负责保护判断是否只需返回缓存结果的先检查后执行的操作序列;
	 * 另一个则负责确保对缓存的数值和因数分解结果进行同步更新。此外,还重新引入了命中计数器,添加了一个
	 * 缓存命中计数器,并在第一个同步代码块中更新这两个变量。由于这两个计数器也是共享可变状态的一部分,
	 * 因此必须在所有访问它们的位置上都使用同步。位于同步代码块之外的代码将以独占方式来访问局部(位于栈上的)
	 * 变量,这些变量不会在多个线程间共享,因此不需要同步。
	 */
    @Override
    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);
    }
}

要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这个需求必须得到满足)、简单性和性能。
无论是执行计算密集的操作,还是执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值