并发编程学习笔记1——可见性、原子性、有序性问题,Java线程

在这里插入图片描述


一、并发编程的主要问题

1.缓存带来的可见性问题

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

在单核时代,所有的线程都是在一颗 CPU 上执行,CPU 缓存与内存的数据一致性容易解决。因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。

多核时代,每颗 CPU 都有自己的缓存,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。
在这里插入图片描述

public class Test {
  private long count = 0;
  private void add10K() {
    int idx = 0;
    while(idx++ < 10000) {
      count += 1;
    }
  }
  public static long calc() {
    final Test test = new Test();
    // 创建两个线程,执行add()操作
    Thread th1 = new Thread(()->{
      test.add10K();
    });
    Thread th2 = new Thread(()->{
      test.add10K();
    });
    // 启动两个线程
    th1.start();
    th2.start();
    // 等待两个线程执行结束
    th1.join();
    th2.join();
    return count; //结果为10000到20000之间随机数
  }
}

2.线程切换带来的原子性问题

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。

高级语言里一条语句往往需要多条 CPU 指令完成,例如count += 1,至少需要三条 CPU 指令。

  1. 把变量 count 从内存加载到 CPU 的寄存器;
  2. 在寄存器中执行 +1 操作;
  3. 将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,当两个线程按照下面顺序执行,结果就为1,而不是2 。
在这里插入图片描述

3.编译优化带来的有序性问题

**有序性指的是程序按照代码的先后顺序执行。**编译器为了优化性能,有时候会改变程序中语句的先后顺序。

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

在jdk1.5之前,这个双重检查创建单例的代码有可能出空指针。

我们以为的 new 操作应该是:

  • 分配一块内存 M;
  • 在内存 M 上初始化 Singleton 对象;
  • 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

  • 分配一块内存 M;
  • 将 M 的地址赋值给 instance 变量;
  • 最后在内存 M 上初始化 Singleton 对象。

优化后可能会产生这种执行顺序:
在这里插入图片描述


二、解决方案

1.按需禁用缓存和编译优化

导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是按需禁用缓存以及编译优化。

JVM 提供了volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。

volatile

声明一个 volatile 变量 volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里x会是多少呢?
    }
  }
}

在 jdk 1.5 以前的版本运行,x 可能是 42,也有可能是 0,变量 x 可能被 CPU 缓存而导致可见性问题。如果在 1.5 以上的版本运行,x 就是等于 42。

Happens-Before 规则

Happens-Before 是指,前面一个操作的结果对后续操作是可见的。

Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens-Before 规则。

程序的顺序性规则

在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。 “x = 42;” Happens-Before 于 “v = true;”。

volatile 变量规则

对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

传递性

如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

在这里插入图片描述

  • “x=42” Happens-Before 写变量 “v=true” ,这是规则 1 的内容;
  • 写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 2 的内容 。
  • 再根据这个传递性规则,我们得到结果:“x=42” Happens-Before 读变量“v=true”。

这就是 1.5 版本对 volatile 语义的增强,使得 x 一定为 42.

管程中锁的规则

对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

线程 start() 规则

主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

Thread B = new Thread(()->{
  // 主线程调用B.start()之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();

线程 join() 规则

如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。

Thread B = new Thread(()->{
  // 此处对共享变量var修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中,var==66

final 关键字

final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以尽量优化。

jdk 1.5 之前,构造函数的错误重排导致线程可能看到 final 变量的值会变化。

Class Reordering {
  final int x = 0;
  int y = 0;
  public void writer() {
    x = 1;
    y = 2;
  }

  public void reader() {
    int r1 = y;
    int r2 = x;
  }
}

线程A执行writer() 方法时,可能会出现重排,先执行 y=2,然后切换到线程B执行 reader(),此时读出来x=0。

在 1.5 以后 Java 内存模型对 final 类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。

在下面例子中,在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的。

final int x;
// 错误的构造函数
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此处就是讲this逸出,
  global.obj = this;
}

2.锁

原子性问题的源头是线程切换。同一时刻只有一个线程执行这个条件非常重要,我们称之为互斥
在这里插入图片描述

锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本上都是下面这个样子:

class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块
  Object obj = new Object()void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}  
  • 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
  • 当修饰非静态方法的时候,锁定的是当前实例对象 this。

用 synchronized 解决 count+=1 问题

class SafeCalc {
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

前一个线程的解锁操作对后一个线程的加锁操作可见,综合 Happens-Before 的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。

所以多个线程同时执行 addOne() 方法,原子性和可见性都可以保证。

但是执行 addOne() 方法后,value 的值对 get() 方法不一定可见,因为没有加锁操作,需要 synchronized 一下即可。

在这里插入图片描述

受保护资源和锁之间的关联关系是 N:1 的关系,反之会出现并发问题。

class SafeCalc {
  static long value = 0L;
  synchronized long get() { //非静态方法
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}

这段代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class。临界区 addOne() 对 value 的修改对临界区 get() 没有可见性保证,这就导致并发问题了。
在这里插入图片描述

保护没有关联关系的多个资源,例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题。

也可以用一把互斥锁来保护多个资源,但是会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。

用不同的锁对受保护资源进行精细化管理,这种锁还有个名字,叫细粒度锁。

保护有关联关系的多个资源,例如银行业务里面的转账操作:

class Account {
  private int balance;
  // 转账
  synchronized void transfer(
      Account target, int amt){
    if (this.balance > amt) {
      this.balance -= amt;
      target.balance += amt;
    }
  } 
}

在这段代码中,临界区内有两个资源,分别是转出账户的余额 this.balance 和转入账户的余额 target.balance,并且用的是一把锁 this,这把锁可以保护自己的余额 this.balance,却保护不了别人的余额 target.balance,就会出现并发问题。

应该用锁覆盖所有受保护资源。在上面的例子中,this 是对象级别的锁,所以 A 对象和 B 对象都有自己的锁。可以让所有对象都持有一个唯一性的对象,这个对象在创建 Account 时传入。但是传入共享的 lock 很难。还可以用 Account.class 作为共享的锁。Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。但是会导致所有账户的转账都是串行的。

原子性的本质是多个资源间有一致性的要求,操作的中间状态对外不可见。

3.死锁

转账时,还可以用两把锁实现,先锁转出账户,再锁转入账户。

class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 锁定转出账户
    synchronized(this) {              
      // 锁定转入账户
      synchronized(target) {           
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

使用细粒度锁可以提升性能,代价就是可能会导致死锁。

死锁是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。

只有以下这四个条件都发生时才会出现死锁:

  • 互斥,共享资源 X 和 Y 只能被一个线程占用;
  • 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  • 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
  • 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

只要破坏其中一个,就可以成功避免死锁的发生。

  1. 互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。

  2. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。

可以增加一个唯一的共享对象,它有两个重要功能,分别是:同时申请资源 apply() 和同时释放资源 free()。当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。具体的代码实现如下。

class Allocator {
  private List<Object> als =
    new ArrayList<>();
  // 一次性申请所有资源
  synchronized boolean apply(
    Object from, Object to){
    if(als.contains(from) ||
         als.contains(to)){
      return false;  
    } else {
      als.add(from);
      als.add(to);  
    }
    return true;
  }
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
  }
}

class Account {
  // actr应该为单例
  private Allocator actr;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 一次性申请转出账户和转入账户,直到成功
    while(!actr.apply(this, target))try{
      // 锁定转出账户
      synchronized(this){              
        // 锁定转入账户
        synchronized(target){           
          if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
          }
        }
      }
    } finally {
      actr.free(this, target)
    }
  } 
}

如果 apply() 操作耗时非常短,而且并发冲突量也不大时,这个方案还挺不错的。但是如果 apply() 操作耗时长,或者并发冲突量大的时候,死循环就太消耗 CPU 了。

可以用等待 - 通知机制

在 Java 语言里,等待 - 通知机制可以有多种实现方式,比如 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现。

当调用 wait() 方法后,当前线程就会被阻塞,当条件满足时调用 notify(),会通知阻塞中的线程,告诉它
条件曾经满足过。因为被通知线程的执行时间点和通知的时间点基本上不会重合。

除此之外,还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁,因为曾经获取的锁在调用 wait() 时已经释放了。

如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。

条件曾经满足过可以用范式:

  while(条件不满足) {
    wait();
  }

改进代码:

class Allocator {
  private List<Object> als;
  // 一次性申请所有资源
  synchronized void apply(
    Object from, Object to){
    // 经典写法
    while(als.contains(from) ||
         als.contains(to)){
      try{
        wait();
      }catch(Exception e){
      }   
    } 
    als.add(from);
    als.add(to);  
  }
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
    notifyAll();
  }
}

尽量使用 notifyAll()。notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。使用 notify() 可能导致某些线程永远不会被通知到。

  1. 破坏不可抢占条件

这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,Lock 是可以轻松解决这个问题的。

  1. 对于“循环等待”这个条件,可以靠按序申请资源来预防。比如可以用账户id,在锁定前比较大小,按照顺序锁定。这个成本相对最低,推荐使用。

三、Java线程

1.Java生命周期与状态转换

通用的线程生命周期基本上可以用下图这个“五态模型”来描述。
在这里插入图片描述

  1. 初始状态
    指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。
  2. 可运行状态
    指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行。
  3. 运行状态
    运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态。
  4. 休眠状态
    释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当唤醒的事件出现了,线程就会从休眠状态转换到可运行状态。
  5. 终止状态
    终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。

Java中的生命周期模型如下:
在这里插入图片描述
Java 里把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而 JVM 层面不关心这两个状态,因为 JVM 把线程调度交给操作系统处理了。

Java 语言中线程共有六种状态,分别是:

  • NEW(初始化状态)
  • RUNNABLE(可运行 / 运行状态)
  • BLOCKED(阻塞状态)
  • WAITING(无时限等待)
  • TIMED_WAITING(有时限等待)
  • TERMINATED(终止状态)
  1. RUNNABLE 与 BLOCKED 的状态转换

只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。

线程调用阻塞式 API 时,在操作系统层面,线程是会转换到休眠状态的,但是在 JVM 层面,Java 线程的状态会依然保持 RUNNABLE 状态。JVM 层面并不关心操作系统调度相关的状态

  1. RUNNABLE 与 WAITING 的状态转换

总体来说,有三种场景会触发这种转换。

  • 获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。
  • 调用无参数的 Thread.join() 方法。当线程执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
  • 调用 LockSupport.park() 方法。其中的 LockSupport 对象,Java 并发包中的锁,都是基于它实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。
  1. RUNNABLE 与 TIMED_WAITING 的状态转换

有五种场景会触发这种转换:

  • 调用带超时参数的 Thread.sleep(long millis) 方法;
  • 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
  • 调用带超时参数的 Thread.join(long millis) 方法;
  • 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
  • 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
  1. 从 NEW 到 RUNNABLE 状态

Java 刚创建出来的 Thread 对象就是 NEW 状态,只要调用线程对象的 start() 方法就可以切换到RUNNABLE状态。

  1. 从 RUNNABLE 到 TERMINATED 状态

线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止。如果要强制终止线程,可以调用 interrupt() 方法。

stop() 和 interrupt() 方法的主要区别

stop() 方法会立刻杀死线程,如果线程持有 ReentrantLock 锁,被 stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁,类似的方法还有suspend()resume() 方法,这三个方法都不建议使用了。

interrupt() 方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。被 interrupt 的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。

当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发 InterruptedException 异常。上面我们提到转换到 WAITING、TIMED_WAITING 状态的触发条件,都是调用了类似 wait()、join()、sleep() 这样的方法,我们看这些方法的签名,发现都会 throws InterruptedException 这个异常。这个异常的触发条件就是:其他线程调用了该线程的 interrupt() 方法。

当线程 A 处于 RUNNABLE 状态时,并且阻塞在 java.nio.channels.InterruptibleChannel 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 会触发 java.nio.channels.ClosedByInterruptException 这个异常;而阻塞在 java.nio.channels.Selector 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 的 java.nio.channels.Selector 会立即返回。

如果线程处于 RUNNABLE 状态,并且没有阻塞在某个 I/O 操作上,调用线程 A 的 interrupt() 方法,那么线程 A 可以通过 isInterrupted() 方法,检测是不是自己被中断了。

2.如何设置合适的线程数

使用多线程,本质上就是提升程序性能。

度量性能的指标有很多,但是有两个指标是最核心的,它们就是延迟和吞吐量

  • 延迟指的是发出请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也就越好。
  • 吞吐量指的是在单位时间内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。这两个指标内部有一定的联系(同等条件下,延迟越短,吞吐量越大),但是由于它们隶属不同的维度(一个是时间维度,一个是空间维度),并不能互相转换。

在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升 I/O 的利用率和 CPU 的综合利用率

如果 CPU 和 I/O 设备的利用率都很低,那么可以尝试通过增加线程来提高吞吐量。

在单核时代,多线程主要就是用来平衡 CPU 和 I/O 设备的。如果程序只有 CPU 计算,而没有 I/O 操作的话,多线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。

但是在多核时代,这种纯计算型的程序也可以利用多线程来提升性能。举个简单的例子说明一下:计算 1+2+… … +100 亿的值,如果在 4 核的 CPU 上利用 4 个线程执行,线程 A 计算[1,25 亿),线程 B 计算[25 亿,50 亿),线程 C 计算[50,75 亿),线程 D 计算[75 亿,100 亿],之后汇总,那么理论上应该比一个线程计算[1,100 亿]快将近 4 倍,响应时间能够降到 25%。一个线程,对于 4 核的 CPU,CPU 的利用率只有 25%,而 4 个线程,则能够将 CPU 的利用率提高到 100%。

创建多少线程合适,要看多线程具体的应用场景。我们的程序一般都是 CPU 计算和 I/O 操作交叉执行的,由于 I/O 设备的速度相对于 CPU 来说都很慢,所以大部分情况下,I/O 操作执行的时间相对于 CPU 计算来说都非常长,这种场景我们一般都称为** I/O 密集型计算**;和 I/O 密集型计算相对的就是 CPU 密集型计算了,CPU 密集型计算大部分场景下都是纯 CPU 计算。I/O 密集型程序和 CPU 密集型程序,计算最佳线程数的方法是不同的。

  • 对于 CPU 密集型计算,多线程本质上是提升多核 CPU 的利用率,创建太多线程也只是增加线程切换的成本。所以,对于 CPU 密集型的计算场景,线程的数量一般会设置为CPU 核数 +1,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。
  • 对于 I/O 密集型的计算场景,如果 CPU 计算和 I/O 操作的耗时是 1:2,如下图所示,则三个线程最合适。理论上公式为 最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)],实际中这个比值是动态变化的,所以要估算这个参数,然后做各种不同场景下的压测来验证我们的估计。
    三线程执行示意图

四、用面向对象写并发程序

1.封装共享变量

把共享变量作为对象的属性,对于共享变量的访问路径就是对象的公共方法,所有入口都要设置并发访问策略。

就拿很多统计程序都要用到计数器来说,下面的计数器程序共享变量只有一个,就是 value,我们把它作为 Counter 类的属性,并且将两个公共方法 get() 和 addOne() 声明为同步方法,这样 Counter 类就成为一个线程安全的类了。

public class Counter {
  private long value;
  synchronized long get(){
    return value;
  }
  synchronized long addOne(){
    return ++value;
  }
}

对于不会发生变化的共享变量,建议用 final 关键字来修饰。这样既能避免并发问题,也能很明了地表明你的设计意图。

2.识别共享变量间的约束条件

库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限,库存下限要小于库存上限。

public class SafeWM {
  // 库存上限
  private final AtomicLong upper =
        new AtomicLong(0);
  // 库存下限
  private final AtomicLong lower =
        new AtomicLong(0);
  // 设置库存上限
  void setUpper(long v){
    // 检查参数合法性
    if (v < lower.get()) {
      throw new IllegalArgumentException();
    }
    upper.set(v);
  }
  // 设置库存下限
  void setLower(long v){
    // 检查参数合法性
    if (v > upper.get()) {
      throw new IllegalArgumentException();
    }
    lower.set(v);
  }
  // 省略其他业务代码
}

问题在于存在竞态条件。代码里出现 if 语句的时候,就应该立刻意识到可能存在竞态条件。

如果线程 A 和线程 B 完全同时执行,分别设置上下限,都会通过参数校验,但是会导致库存下限大于库存上限。

解决方法:给 set 方法加 synchronized 。

3.制定并发访问策略

  • 避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
  • 管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。

原则主要有以下三条:

  • 优先使用成熟的工具类。
  • 迫不得已时才使用低级的同步原语:主要指 synchronized、Lock、Semaphore 等。
  • 避免过早优化:安全第一。

思考题

1、有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,有哪些办法可以让其他线程能够看到?

: 1. 用volatile 修饰 abc。
2. 在给 abc 赋值的地方用 synchronized 括起来。
3. 线程启动后,用 join() 方法等待返回,后续线程再运行。
4. 在abc赋值后对一个volatile变量A进行赋值操作,然后在其他线程读取abc之前读取A的值,通过volatile的可见性和happen-before的传递性实现abc修改后对其他线程立即可见。


2、下面的代码用 synchronized 修饰代码块来尝试解决并发问题,你觉得这个使用方式正确吗?有哪些问题呢?能解决可见性和原子性问题吗?

class SafeCalc {
  long value = 0L;
  long get() {
    synchronized (new Object()) {
      return value;
    }
  }
  void addOne() {
    synchronized (new Object()) {
      value += 1;
    }
  }
}

:都不能,因为每次调用时,锁住的对象都不同,互相之间不互斥。


3、破坏占用且等待条件时,我们也是锁了所有的账户,而且还是用了死循环 while(!actr.apply(this, target));,那它比 synchronized(Account.class) 有没有性能优势呢?

: 虽然看起来 while(!actr.apply(this, target));只是锁住了两个对象,但是因为actr是一个单例的对象,这个方法在执行的时候也需要锁住actr,在多线程状态下也相当于是串行化了。
区别在于,如果转账操作很耗时,那么a-b,c-d都获取到锁之后能并行还是有价值的。


4、wait() 方法和 sleep() 方法都能让当前线程挂起一段时间,那它们的区别是什么?

:wait()是Object类的方法,sleep()是Thread类的方法。sleep必须指定时间。wait必须在同步代码块内。调用wait 会释放锁,sleep不会。


5、下面代码的本意是当前线程被中断之后退出,你觉得这段代码是否正确呢?

Thread th = Thread.currentThread();
while(true) {
  if(th.isInterrupted()) {
    break;
  }
  // 省略业务代码无数
  try {
    Thread.sleep(100);
  }catch (InterruptedException e){
    e.printStackTrace();
  }
}

:可能出现无限循环,线程在sleep期间被打断了,抛出一个InterruptedException异常,try catch捕捉此异常,应该重置一下中断标示,因为抛出异常后,中断标示会自动清除掉!

 try {
    Thread.sleep(100);
  }catch (InterruptedException e){
    Thread.currentThread().interrupt();
    e.printStackTrace();
  }

参考资料:王宝令----Java并发编程实战

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值