Java并发编程实战2-线程安全性

246 阅读4分钟

一、介绍

1.1 如何写出线程安全的代码

  • 核心在于要对 状态访问操作 进行管理。
  • 特别是对共享的(Shared)可变的(Mutable)状态的访问。

1.2 共享、可变的含义

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

1.3 对象是否需要是线程安全的?

  • 取决于是否被多个线程访问。
  • 这里是指 在程序中访问对象的方式,而不是对象要实现的功能。
  • 如果要使得对象是线程安全的,需要采用同步机制,协同线程对对象可变状态的访问

1.4 如何协同线程对对象可变状态的访问?

  1. 关键字 synchronized
  2. volatile 类型的变量
  3. 显示锁 (explicit lock)
  4. 原子变量 (juc包):实现在数值和对象引用上的原子状态转换。
    • AtomicBoolean
    • AtomicLong
    • AtomicInteger
    • AtomicReference

1.5 如何修复多线程访问同一个可变状态变量的问题?

前提条件:假设这个问题是由于 未使用合适的同步造成的 解决:

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

1.6 如何设计一个线程安全的类?

  1. 良好的面向对象技术
  2. 不可修改性
  3. 明晰的不变性规范

二、什么是线程安全性

2.1 定义

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

2.2 正确性的定义

  • 某个类的行为与其规范完全一致。
  • 在良好的规范中通常会定义各种 不变性条件(Invarient)来约束对象的状态,以及定义各种 后验条件(Postcondition)来描述对象操作的结果

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

三、原子性

3.1 竞态条件(Race Condition)

  1. 定义
    • 由于 不恰当的执行时序而出现的不正确的结果
  2. 本质:
    • 基于一种可能失效的观察结果来做判断或者执行某个计算,从而导致各种问题。
  3. 常见的竞态条件
    • 先检查后执行 :通过一个可能失效的观测结果来决定下一步的动作。
    • 读取-修改-写入 :基于对象之前的状态来定义对象状态的转换。

3.2 复合操作

  • 以上两种竞态条件均涉及复合操作。
  • 包含了一组必须以 原子方式 执行的操作以确保线程安全性。

3.3 原子操作

  • 对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。

四、加锁机制

4.1 内置锁

  1. 同步代码块( Synchronized Block):Java提供的一种内置的锁机制来支持原子性。
  2. 同步代码块的组成:两部分
    • 锁的对象引用
    • 锁保护的代码块
  3. 同步代码块语法格式:
    synchronized (lock) {
      // 访问/修改由锁保护的共享状态
    }
  1. static synchronized方法以 Class对象 作为锁。
  2. 内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock)
    • 每个Java对象都可以用做一个实现同步的锁,这些锁被称为 内置锁 或者 监视器锁。
    • 线程在进入同步代码块之前会自动获得锁,并在退出同步代码块之后释放锁。
    • 每次只能有一个线程执行内置锁保护的代码块。
  3. 内置锁是 可重入锁
    • 重入意味着 获取锁操作的粒度是线程 ,而不是调用。

五、用锁来保护状态

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

六、demo

6.1 需求说明

  1. 前提:假设我们要获取去用户请求的数量,然后进行自增的操作。
  2. 要求:考虑多线程执行,使得 main 方法输出正确的结果。
  3. 基本的类定义如下:
public class UserReq {

    private long count;

    public long getCount() {
        return count;
    }

    public void addCount() {
        // 为了看出计数效果,这里在方法内部循环1000次
        for (int i = 0; i < 1000; i++) {
            count++;
        }
    }
}
  1. 测试方法:使用 main 方法执行,方法如下。
public class UserReqTest {
    public static void main(String[] args) throws InterruptedException {
        int loopNum = 1000;
        UserReq userReq = new UserReq();
        for (int i = 0; i < loopNum; i++) {
            new Thread(userReq::addCount).start();
        }
        // 主线程sleep一会,等待全部子线程执行完毕。
        Thread.sleep(1000 * 5);
        System.out.println(userReq.getCount());
        // output:999103。这里每次执行输出都不太一样,但是不是 1000 * 1000
    }
}
  1. 说明UserReq 中计数方法 count++ 是一个复合操作,必须以原子方式执行才能保证线程安全。

6.2 改进一:使用 synchronized 关键字

  1. 改进方法:
    • UserReq 类中方法addCount上添加 synchronized 关键字。
    • 该同步代码块的锁就是方法调用时的对象 userReq (main方法中创建的)。
  2. 改进代码:忽略
  3. 问题:性能问题

6.2 改进:使用原子变量来计数

  1. 改进方法:
    • UserReq 类中使用原子变量 AtomicLong 类型的变量代替 基础数据类型 long
  2. 改进代码:
public class UserReq {

    private final AtomicLong count = new AtomicLong(0);

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

    public void addCount() {
        // 为了看出计数效果,这里在方法内部循环1000次
        for (int i = 0; i < 1000; i++) {
            count.incrementAndGet();
        }
    }
}
// main 方法输出:1000000
  1. 思考
    • 这里只有一个变量需要使用原子变量来协同状态变化,如果有 多个共享的可变变量 ,只通过增加原子变量能满足线程安全要求吗?

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

    • 主线程无需使用Thread.sleep(1000 * 5)的操作等待线程执行完成;可以使用 二元闭锁 ,参考 【Ch5-基础构建模块】