第二章 线程安全性

涉及到的概念:核心概念 线程的安全性

问题:

  • 什么是线程安全性?
  • Java 怎样保证线程的安全?
  • 线程的安全性从哪些方面进行保证?
  • 具体的场景?

本章涉及到的概念:

  • 线程安全性
  • 对象状态
  • 不变性条件、后验条件、无效状态
  • 原子性与原子操作
  • 竞态条件(Race Condition)
  • 竞态条件的典型场景
  • 怎样避免竞态条件
  • Java 的锁机制
    • 锁对象
    • 临界区
  • 锁重入

下面 正文开始

编写程序,最核心的就是程序的 「正确性」 。 而多线程环境下 安全性 与 正确性 ,二者可以算作近似一致的概念。

多线程环境 下保证程序的正确性,分为两方面:

  • 对象本身具有某种特性 : 对象不存在 「可变」 、「共享」 的状态。
  • 外部某种机制

也很好理解,如果一个不那么"安全" 的对象,放在安全的环境中(比如单线程),那么并不会发生错误,而如果不安全的环境,那么要么对象自身是安全的,要么使用 Java 提供的机制来保证安全。

那么这里就来到了第一个概念: 线程安全性,遇到一个概念,那么首先就是要对概念下一个定义:

  • 什么是线程安全性?

书中给出的定义是:

  • 线程安全性的核心是 「正确性」 。

那么问题来了,正确性的定义是什么?

  • 正确性指的是某个类的行为与规范完全一致。

这句话理解很抽象,行为指的是啥?规范又是什么? 书中进一步进行了解释:

  • 所谓「规范」:就是程序中定义的 「不变性条件」 和 「后验条件」,不变性条件 是对对象某些状态的约束,比如某个量是常量,无论如何运算都不能发生变化,如果发生变化了,那就是出 BUG了。 后验条件则是对程序操作结果的描述,比如一个程序是用来记录气温的,记录值是零下1000度,这明显超出了合理范围,也就不符合 后验条件

这里可以将单线程程序定义为线程安全的,所以如果一个程序在多线程环境下与单线程环境中表现一致,就可以认为这个程序是可以在多线程环境中正确运行的。

  • 但是其实这里有个问题,就是这句话还是比较抽象,中是下了个定义,不过这才是本书的刚开始,继续往后看看有没有更确切的描述。

下面就来到了本章的第一个示例,一个无状态的 Servlet , Servlet 频繁被作者拿出来举例就是因为其天然处于多线程环境中,一个 Servlet 作为一个服务的提供端点,可能同时被多个客户端进行访问,所以用来作为多线程安全性的测试非常的合适。

程序清单 2-1 无状态 Servlet

@ThreadSafe
public class StatelessFactorizer extends GenericServlet implements Servlet {
    // 对外提供的具体 service
    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }

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

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

    BigInteger[] factor(BigInteger i) {
        return new BigInteger[]{i};
    }
}

可以看到这个程序中不存在任何的类字段,也就是说这个 Servlet 没有自己的状态,所以这是一个线程安全的 Servlet ,无论多少个线程同时访问它,都不会导致错误发生。

这里就暴露出了我认为本书的第一个问题,这里只提供了代码段,这里我很理解,因为受制于篇幅,没有提供完整的代码,但是去了 jcip 图书的对应资源站之后你看到的完整的代码也是无法运行,无法测试的,这就不能为我们的学习带来最直观的体验。

不过具体到这个代码里,确实太简单了,因为没有类字段,都是方法中的局部变量,而局部变量是被封闭在栈中的,也就是每个线程都有自己的变量,属于非共享类型的变量,自然也就不存在多线程问题。

作为对比,就要来一个线程不安全的 Servlet

// 非线程安全, ++count  不是原子操作,分为读取,操作,写入 三个步骤
//UnsafeCountingFactorizer.java
@NotThreadSafe
public class UnsafeCountingFactorizer extends GenericServlet implements Servlet {
    // 定义了一个变量计数器
    private long count = 0;

    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        // 不安全的操作,并发时可能会产生问题
        ++count;
        encodeIntoResponse(resp, 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};
    }
}

这个 Servlet 中包含了一个状态 : count 字段,这个字段用来记录这个 Servlet 被访问到的数量,但是对这个字段的操作却是非线程安全的,因为 ++count 包含了 读取——修改——写入 三个操作,而这三个操作在多线程环境中可能导致错误的发生。

这里我增添的 JUNIT 测试代码如下: 并略微修改了 Servlet 中的方法

public class UnsafeCountingFactorizerTest {
    /**
     * 启动 1000 个线程同时访问 UnsafeCountingFactorizer ,输出最后的计数器的值
     */
    private UnsafeCountingFactorizer servlet;
    HttpServletRequest request;
    HttpServletResponse response;
    UnsafeCountingFactorizerTest test;

    @Before
    public void setUp() {
        test = new UnsafeCountingFactorizerTest();
        test.request = mock(HttpServletRequest.class);
        test.servlet = new UnsafeCountingFactorizer();
        test.response = mock(HttpServletResponse.class);
    }

    public void testService() {
        servlet.service(request, response);
    }

    @Test
    public void testCounting() throws InterruptedException {
        Thread t = new Thread(test::testService);
        Thread t2 = new Thread(test::testService);
        t.start();
        t2.start();
        t2.join();
        t.join();
        assertEquals(2000000, test.servlet.getCount());
    }
}

结果可以看到,结果充满了随机性和不正确性

image-20201211230302860

image-20201211230427243

原子性问题的示意图:

这里 读取—修改—写入 这一组操作由于依赖特定的正确顺序,所以这组操作中存在 竞态条件。 这里作者也给出了竞态条件的定义。

  • 竞态条件:当程序的正确性取决于多个线程交替执行的时序时,就产生了竞态条件。
  • 竞态条件的典型场景:「先检查,后执行」,因为检查的值在并发环境中可能已经失效,从而导致错误。
  • 竞态条件的本质:观察结果的失效。(这在并发环境中是非常平常的事情)

另一个例子:单例延迟初始化中的竞态条件

上面说了,先检查后执行是竞态条件的典型情况之一,而 「延迟初始化」 又是 先检查后执行的典型情况之一,这种情况来自于单例模式中。

单例中存在一个检查对象是否已经创建的步骤,这一步就是典型的 「检查—执行」 步骤,下面是书中给的代码片段:

@NotThreadSafe
public class LazyInitRace {
  private ExpensiveObject instance = null;
  public ExpensiveObject getInstance() {
    // 如果多个线程在这里同时看到 instance == null 成立,那么可能会调用多次构造方法,导致出现问题
    if (instance == null) {
      instance = new ExpensiveObject();
    }
    return instance;
  }
  public static void main(String[] args) {
    Set<Object> objects = new HashSet<>();
    objects.add(1111);
  }
}

下一步我需要思考的就是:怎样创建一个场景,让多个线程同时看到 instance == nulltrue ,也就是编写对应的测试代码。

测试代码:

public class LazyInitRaceTest {
    LazyInitRace lazyInitRace;

    @Before
    public void setUp() {
        lazyInitRace = new LazyInitRace();
    }

    @Test
    public void testLazyInit() {
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(() -> lazyInitRace.getInstance());
            t.start();
        }
        Assert.assertEquals(1,lazyInitRace.getCount());
        System.out.println(lazyInitRace.getCount());
    }
}

这里我同时创建10个线程调用单例类创建实例的方法,实际测试起来倒是比想象中的更顺利,想着可能并不容易再现多个线程同时判断 instance == null ,后来发现非常容易进入这个判断,并且这个线程数量至少为6,原因我想因为我的 mac 是 6核 CPU ,也就是运行时,可以有6个线程在不同的 CPU 上同时执行。

执行结果:

image-20201212013832422

image-20201212013855943

这里我给原书中的例子增加了一个计数器,记录进入判断条件的线程的次数,以下是代码段:

AtomicInteger i = new AtomicInteger(0);
public ExpensiveObject getInstance() {
  if (instance == null) {
    i.incrementAndGet();
    instance = new ExpensiveObject();
  }
  return instance;
}
public int getCount() {
  return i.intValue();
}

可以看到,如果是真实场景,这样就会导致多个线程同时调用单例类的构造函数,从而导致问题发生。

这里 「竞态条件」 和 「组合操作」 两个概念是相关的,如果一组操作是组合操作而不是原子操作,就会在 多线程环境 下出现竞态条件的问题。

这里我们已经看到了 「竞态条件」 导致的问题,那么如何解决呢?

Java 中的锁机制

synchronizedJava 中的锁关键字,synchronized 可以修饰一个代码块,也可以修饰一个方法,被 synchronized 包裹的代码块,被称为 「临界区」。

synchronized(锁对象) {
  // 临界区,只有获取了锁对象的线程才能进入到这里
}

Java 中锁最大的作用就是保证了互斥,只有获取了锁对象的线程才能进入临界区,这就将多线程转变为了串行单线程,所以 缺点 你也发现了,锁是一种重型同步手段,如果应用的不好的话,会明显降低性能。

下面是 Java 所采用的管程模型示意图,至于什么是管程,可以看 极客时间同名课程的第八章,详细介绍了管程,以及 Java 为什么采用 synchronized 这种模型。

这里有一个之前会发生竞态条件的 Servlet , 被 synchronized 修饰之后就不会发生安全问题了,示例代码:

public class SynchronizedFactorizer extends GenericServlet implements Servlet {
  // 这里的 @GuardedBy 指的是被内置锁 synchronized 对象保护 没有实际意义,是一个语义化的注解
  @GuardedBy("this")
  private BigInteger lastNumber;
  @GuardedBy("this")
  private BigInteger[] lastFactors;
	
  // 重点看这里,默认的 synchronized 锁对象是 this , 也就是 SynchronizedFactorizer 类的实例对象
  // 只有获取了 SynchronizedFactorizer 对象的线程才能进入 service 方法
  // 但是这也导致每次只能处理一个请求
  @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);
    }
  }

  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};
  }
}

这里我画了一个示意图,当 service 方法被 synchronized 关键字修饰时, 编译器会自动生成对应的 加锁和解锁字节码,这时在多线程环境中多个线程同时访问 service 方法,只有一个线程能获取锁对象,其他线程进入 阻塞 状态,等待获取锁。

锁重入

Java 的内置锁是可重入的,具体解释就是:锁中有一个计数器和一个持有锁的线程标记,当线程重复获取锁时,计数器加1,当计数器为0时,则代表锁没有被任何线程持有。

这代表着 Java 中所的操作粒度是 「线程」 而不是 「调用」 。

如果 Java 内置锁不支持重入会怎样?

public class Father {
  // 父类中有一个加锁的方法
  public synchronized void doSomething() {
    // ...
  }
}

public class Son  extends Father{
  // 覆写父类方法,增加子类独有行为
  public synchronized void doSomething() {
    System.out.printlin("子类的 doSomething 被调用了");
    // 关键在这里如果锁不可重入,那么调用父类方法时需要等待获取锁,而锁已经被子类获取了,导致死锁
    super.doSomething();
  }
}

如上述伪代码所示,如果不支持锁重入,那么在子类中调用父类的加锁方法,需要等待锁的获取,而锁已经被获取了(在进入子类方法的时候),所以就会导致无限等待获取父类方法上的锁,从而造成 「死锁」 。

锁的正确使用方式

由于锁的互斥特性,所以可以将对 对象共享状态的访问方法用锁保护起来,比如将一组复合操作进行封装,那么这一组操作被锁保护之后就可以保证状态的一致性。(因为只有一个线程能修改被锁保护的状态)

但是并不是对变量进行修改时才需要同步,当一个 共享可变状态 暴露在多线环境中,访问它的时候也需要持有锁,这样才能保证可见性, 我们称这个对象的状态是被某个锁保护的。

关键点:当使用锁保护多个状态时,需要是同一个锁对象

以下示意图来自极客时间的同名课程的第四章,关键一把锁保护多个状态变量的示意图:

锁对活跃性以及性能可能造成的影响

还是上面的图,但是这次加了个条件,那就是 service 方法本身非常耗时,这就导致了原本可以同时被多个线程同时访问的方法,此时变为串行访问,其他等待线程需要等很久才能有返回数据。

这就是使用锁从而对活跃性和性能造成了重大影响的例子。锁是一种比较极端的同步手段,因为直接将并行互斥为了串行。

image-20201212180715151

于是我们在使用锁的时候需要在

  • 简单性 : 直接在整个方法上简单粗暴的增加个锁
  • 并发性: 尽量让锁的包裹度更小,只包裹最必要的代码,以减小对性能的影响

这二者之间需要做出权衡,于是有了修改之后的 Servlet , 下面这个例子只在必要的地方加锁,保证了性能的同时也保证了程序在多线程环境下的正确性:

// 使用更细粒度的内置锁既保证线程安全,又保证了代码的性能与活跃性。
// CachedFactorizer.java
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 getCacheHitRation() {
    return (double) cacheHits / hits;
  }

  // 涉及对对象实例状态的操作都需要被内置锁保护使 操作实力域的代码块成为原子操作
  @Override
  public void service(ServletRequest req, ServletResponse resp) {
    BigInteger i = extractFromRequest(req);
    BigInteger[] factors = null;
		
    // 加锁地点 ①:因为要对 lastNumber 进行判断
    synchronized (this) { 
      ++hits;
      if (i.equals(lastNumber)) {
        factors = lastFactors.clone();
      }
    }


    if (factors == null) {
      // 下面这部分不需要被锁保护,因为并没有涉及共享变量,涉及到的是方法的局部变量,这种变量是栈封闭的,天生线程安全
      factors = factor(i);
      // 加锁地点②:对于两个可变状态的操作,需要用锁来保证同步
      synchronized (this) {
        lastNumber = i;
        lastFactors = factors.clone();
      }
    }
    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};
  }
}

本章小结

其实这一章只是粗略的简单介绍了 Java 中 「锁」 这方面的同步机制,是从同步机制中又选取了一个小的切面进行切入,下一章就该从对象本身入手来讲解怎么在多线程环境下保证程序的正确性了。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。