【并发编程】2、线程安全性

1、什么是线程安全性?

  我们来看一下书里面是怎么写的:

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

  也就是说,在多线程环境下,我们的线程也同样能够安全的运行。简单的举一个例子:一个无状态的 Servlet。

@ThreadSafe
public class StatelessFactorizer implements Servlet {
    @Override
    public void service(ServletRequest req, ServletResponse resp) throws ServletException, IOException {
        BigInteger i = extractFromRequest(req); // 获取到请求参数
        BigInteger[] factors = factor(i); // 因式分解
        encodeIntoResponse(resp, factors); // 返回结果
    }
}

  StatelessFactorizer 这个 Servlet 是无状态的:它既不包括任何域(成员),也不包括任何对其他类中域的引用。

无状态的类是线程安全的。

  大多数 Servlet 都是无状态的,从而极大地降低了在实现 Servlet 线程安全性时的复杂性。只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题。

2、原子性

  当我们在无状态的类里面加入一个状态,会变成什么样?比如需要记录 Servlet 的访问量。很直观的解决方式肯定就是添加一个变量来记录访问了多少,如下:

@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
    private long count = 0;

    public long getCount() {
        return count;
    }

    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req); // 获取到请求参数
        BigInteger[] factors = factor(i); // 因式分解
        count++;
        encodeIntoResponse(resp, factors); // 返回结果
    }
}

  但是遗憾的是上面的代码并不满足线程安全性,在单线程的环境下运行代码没有问题,但是在多线程环境下就有可能出现问题,来看一下 count 这个字段的增加的时候也就是 count++ 其实看上去只有一个操作,但是这个操作并不是原子性的,实际需要有三步:读取-修改-写入。
在这里插入图片描述
  可能在获取 Servlet 的访问量的数量准确度存在少量误差是可以理解,也的确是这样,但是这个计数器是用来处理数值序列或者唯一的对象标识符的话,那么就会出现在多次调用出现相同的值而导致严重的数据完整性问题。在并发编程中,这种不恰当的时序导致出现不正确的结果,我们叫做 竞态条件(Race Condition)。

2.1 竞态条件

  在 UnsafeCountingFactorizer 里面就存在竞态条件,让结果变得不可靠,最常见的竞态条件就是 “先检查,后执行(Chick-Then-Act)” 操作。

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

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

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

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

  在 getInstance 中会先判断 instance 是否为 null,如果有,返回现有实例,否则,新建一个对象返回。其中也会存在竞态条件,破坏类的正确性。假如有 A 线程和 B 线程,A 看见 instance 为 null,去新建一个对象,然而 B 在调用 getInstance 方法时也需要去判断 instance 是否为空。现在的 instance 是否为空,需要取决于不可预测的时序,假如 B 在调用的时间为 A 判断 instance 为 null,还没有新建的时候,那么,A、B两个线程调用 getInstance 返回的对象就有可能不一样。
  与大多数错误一样,竞态条件并总会产生错误,还需要某种不恰当的时序,但是,竞态条件也会导致一些严重的问题。比如使用 LazyInitRace 来创建应用程序的注册表,如果多次调用返回不同的结果,那么要么失去部分注册信息,要么多个行为对同一组注册对象表现出不一样得视图。

2.3 复合操作

  UnsafeCountingFactorizer 和 LazyInitRace 都包含一组需要以原子操作方式执行,要避免竞态条件问题,就需要在某个线程修改该变量时阻止其他线程使用这个变量,从而保证数据得正确性。
  像 “先检查,后执行”(如延迟初始化) 和 “读取-修改-写入”(如递增运算)等必须是原子的操作,我们叫做复合操作 ,如果 UnsafeCountingFactorizer 的递增是原子操作,竞态条件就不会发生,我们使用现有的安全类来修复这个问题:

@ThreadSafe
public class CountingFactorizer implements Servlet {
    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(); // 增加1
        encodeIntoResponse(resp, factors); // 返回结果
    }
}

  在 java.util.concurrent.atomic 这个包下提供了一些原子变量类,用于实现在数值和对象引用上的原子转换,这样一来,CountingFactorizer 这个 Servlet 就是线程安全的类了。

3、加锁机制

  在 Servlet 里加入一个状态变量时,可以通过线程安全的对象去管理来维护线程安全性,那如果 Servlet 需要多个状态变量,是否只需要添加多个线程安全的对象就可以保证线程安全性?
  假如我们想要提高 Servlet 的性能:将最近的结果缓存起来,当两次传入的参数相同时,我们就直接在缓存里面去获取,不需要重新计算。之前我们使用 AtomicLong 这个线程安全类来处理计数,现在我们能否使用功能类似的 AtomicReference 来管理分解的因素的数值和其结果呢?

@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
    private AtomicReference<BigInteger> lastNum = new AtomicReference<>();
    private AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();

    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        if (i.equals(lastNum.get())) {
            encodeIntoResponse(resp, lastFactors.get());
        } else {
            BigInteger[] factors = factor(i);
            lastNum.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }
}

  但是,UnsafeCountingFactorizer 是线程不安全的,其中也存在竞态条件,可能让结果不正确,因为在线程安全性定义中要求,多个线程之间采用何种执行时序或者交替方式都要保持其不变性条件不被破坏,在 UnsafeCountingFactorizer 的不变条件是 lastFactors 的积应该等于 lastNum 的值,虽然说 lastFactors 和 lastNum 单独来说都是原子的,但是在多线程环境下,不能保证 lastFactors 和 lastNum 同时是原子的,也就说 UnsafeCountingFactorizer 的不变条件可能会被破坏。假如 A线程 和 B线程,A线程获取 lastNum 的时候,B线程有可能已经将 lastFactors 修改。
在这里插入图片描述

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

3.1 内置锁

  Java提供一种内置锁方式来支持原子性:同步代码块(Synchronized Block),其包括两个部分:(1)作为锁对象的引用。(2)锁保护的代码块。使用synchronized 修饰的方法就是一个同步代码块,静态方法锁定的是 Class 对象。

synchronized (lock) {
	// 被保护的代码
}

  每一个 Java 对象都可以用作一个实现同步的锁,这样的锁被叫做内置锁(Intrinsic Lock)或者叫做监视器锁(Monitor Lock)
  Java的内置锁相当于一种互斥锁,在同一时刻只能有一个线程获取该锁。如果 A 线程想要获取到 B 线程拥有的锁,那么 A 线程就只能等待或者阻塞,等待 B 线程释放锁,如果 B 线程一直没有释放锁,那么 A 线程就只能一直等待下去。
  现在就可以对 UnsafeCountingFactorizer 进行升级得到线程安全的类,如下:

@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
    @GuardedBy("this") private BigInteger lastNumber;
    @GuardedBy("this") private BigInteger[] lastFactors;
    // @GuardedBy("this") 表示该字段受保护,锁来源是()里面的值
    @Override
    public synchronized void service(ServletRequest req, ServletResponse resp) throws ServletException, IOException {
        BigInteger i = extractFromRequest(req);
        if (i.equals(lastNumber)) {
            encodeIntoResponse(resp, lastFactors);
        } else {
            BigInteger[] factors = factor(i);
            lastNumber = i;
            lastFactors = factors;
            encodeIntoResponse(resp, factors);
        }
    }
}

3.2 重入

  重入的定义是:如果某个线程想要获取它已经持有的锁,那么请求就会成功。

public class Widget {
    public synchronized void doSomething() {
        System.out.println("this is Widget!");
    }
}

public class LoggingWidget extends Widget{
    public synchronized void doSomething() {
        System.out.println("this is LoggingWidget!");
        super.doSomething();
    }
}

  上面的代码如果没有实现重入,那么就会发生死锁,先获取到 LoggingWidget 的锁,然后执行 super.doSomething(); 再去获取 Widget 的锁,如果没有实现重入,那么将会一直等待下去,永远不能获取到锁。重入则避免了这种情况。

4、活跃性与性能

  在上面的 SynchronizedFactorizer 这个 servlet 在实际运用的时候会发现效果特别差,我们使用的 Servlet 本来就是使用多线程来控制并发的,但是你给 Servlet 加了一个锁,导致同一时刻只能有一个线程,违背的 Servlet 的初心,也就导致了,每发一个请求都要花大量的时间才能得到响应,甚至是得不到响应。
  想要解决这个办法其实很简单,问题出在给Servlet加了锁,我们其实并不需要全部只能一个线程访问,我们只需要线程在访问公共字段/数据的时候再加锁就可以了,将锁的范围缩小。

@ThreadSafe
public class CacheFactorizer implements Servlet {
    @GuardedBy("this") private BigInteger lastNumber;
    @GuardedBy("this") private BigInteger[] lastFactors;
    @GuardedBy("this") private long hits = 0;
    @GuardedBy("this") private long cacheHits = 0;

    public synchronized long getHits() {
        return hits;
    }

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

    @Override
    public void service(ServletRequest req, ServletResponse resp) throws ServletException, IOException {
        BigInteger parm = extractFromRequest(req); // 获取参数
        BigInteger[] factors = null;
        synchronized (this) {
            hits++;
            if (parm.equals(lastNumber)) {
                cacheHits++;
                factors = lastFactors.clone();
            }
        }
        if (factors == null) {
            factors = factor(parm); // 因式分解
            synchronized (this) {
                lastNumber = parm;
                lastFactors = factors;
            }
        }
        encodeIntoResponse(resp, factors); // 响应结果
    }
}

从上面可以看见,如果一个线程持有锁的时间过长,就会带来活跃性的问题。

总结

  这一章的内容不算是很多,主要是理论的知识,在有代码的支持下思路清晰一些。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值