线程安全

概述

如果当多个程序访问一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误,有三种方式可以修复这个问题:
1. 不在线程之间共享该状态变量
2. 将状态变量修改为不可变的变量
3. 在访问状态变量时使用同步

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

原子性

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。最常见的竞态条件类型就是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步的动作。

使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次(如单例模式的恶汉模式)。

java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用的原子状态转换,通过用AtomicLong代替long类型的计数器,能确保所有对计数器状态的访问操作都是原子的。

加锁机制

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

如果我们使用类似的AtomicReference来管理最近执行因数分解的数值及其分解结果,代码如下:

package beidao.multithread.demo;
import java.math.BigInteger;
import java.util.concurrent.atomic.AtomicReference;

import com.mmall.concurrency.annoations.NotThreadSafe;

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

尽管这些原子引用本身都是线程安全的,但在UnsafeCachingFactorizer中存在着竞态条件,这可能产生错误的结果。在使用原子引用的情况下,尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumber和lastFactors。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破坏了。同样,我们也不能保证同时获取这两个值:在线程A获取这两个值得过程中,线程B可能修改了它们,这样线程A也会发现不变性条件被破坏了。

内置锁

Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。

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

重入

内置锁是可重入的,这意味着如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获得锁的操作的粒度是“线程”而不是“调用”。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。

重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。如下代码中,子类改写了父类的sychronized方法,然后调用父类中的方法,此时如果没有可重入锁,那么这段代码将产生死锁。由于Widget和LoggingWidget中doSomething方法都是synchronized方法,因此调用子类的domething()都会获取Widget上的锁,如果内置锁不可重入,那么调用super.doSomething()时将无法获得Widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去。

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

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

用锁来保护状态

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

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全类中都使用了这种模式。

如果同步可以避免竞态条件问题,那么为什么不在每个方法声明时都使用关键字synchronized?事实上,如果不加区别地滥用synchronized,可能导致程序中出现过多的同步。此外,如果只是将每个方法都作为同步方法,例如Vector,那么并不足以确保Vector上复合操作都是原子的:

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

虽然contains和add等方法都是原子方法,但在上面这个“如果不存在则添加”的操作中仍然存在竞态条件。虽然synchronized方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还是需要额外的加锁机制。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值