返回文章列表
JUC并发编程
JUCsynchronizedMonitor线程安全

03共享模型之管程(上):synchronized与Monitor

本章进入「共享模型之管程」。多个线程访问同一个共享变量时,指令交错会导致结果不可预测。本文先从 i++ 的线程安全问题讲起,介绍临界区与竞态条件,再讲解 synchronized 的三种用法、变量的线程安全分析方法,最后深入 Java 对象头与 Monitor 的工作原理。

4.1 共享带来的问题

小故事

老王(操作系统)有一个功能强大的算盘(CPU),想把它租出去赚点外快。小南、小女(线程)来使用算盘做计算,并按时间付费。

  • 小南不能一天 24 小时使用算盘,他经常要小憩一会(sleep),又或是去吃饭上厕所(阻塞 IO 操作),有时还需要一根烟,没烟时思路全无(wait),这些情况统称为阻塞。
  • 在这些时候算盘没利用起来(不能收钱了),老王觉得不划算;另外小女也想用算盘,总让小南占着也不公平。
  • 于是老王想了个办法:每人用一会,轮流使用算盘。这样当小南阻塞时,算盘可以分给小女,反之亦然。

最近的计算比较复杂,需要存储中间结果,而学生们的脑容量(工作内存)不够,老王申请了一个笔记本(主存),把中间结果先记在本上。

但由于分时系统,事故还是发生了:

  1. 小南刚读取初始值 0 做了 +1 运算,还没来得及写回结果,时间片就到了,只能念叨着「结果是 1」到一边等待(上下文切换)。
  2. 小女看到笔记本上还写着 0,做了 -1 运算,把 -1 写入笔记本。
  3. 小女时间片用完,老王又叫醒小南,小南把脑海中的结果 1 写入笔记本。

小南和小女都觉得自己没做错,但笔记本里的结果是 1 而不是 0。

分时复用下两个线程的读写指令交错,最终结果错误

Java 代码的体现

两个线程对初始值为 0 的静态变量一个做自增、一个做自减,各做 5000 次,结果一定是 0 吗?

static int counter = 0;
 
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            counter++;
        }
    }, "t1");
 
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            counter--;
        }
    }, "t2");
 
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    log.debug("{}",counter);
}

运行结果可能是正数、负数或零。

问题分析

Java 中对静态变量的自增、自减并不是原子操作,从字节码可以清楚看到。例如 i++(i 为静态变量)实际会产生如下 JVM 字节码指令:

getstatic     i  // 获取静态变量i的值
iconst_1         // 准备常量1
iadd             // 自增
putstatic     i  // 将修改后的值存入静态变量i

而 i-- 类似:

getstatic     i  // 获取静态变量i的值
iconst_1         // 准备常量1
isub             // 自减
putstatic     i  // 将修改后的值存入静态变量i

Java 的内存模型中,完成静态变量的自增、自减需要在主存和线程的工作内存之间进行数据交换:

主存中的 static 变量与两个线程工作内存之间的数据交换

单线程下这 8 行代码顺序执行(不会交错),没有问题。但多线程下指令可能交错运行:

  • 出现负数的情况:线程 2 先读取 0 完成 isub 得到 -1(尚未写回),上下文切换;线程 1 读取 0 完成 iadd 得到 1,先写回 1;切换回线程 2 后写回 -1。
  • 出现正数的情况:线程 1 先读取 0 完成 iadd 得到 1(尚未写回),上下文切换;线程 2 读取 0 完成 isub 得到 -1,先写回 -1;切换回线程 1 后写回 1。

临界区 Critical Section

  • 一个程序运行多个线程本身是没有问题的,问题出在多个线程访问共享资源。
  • 多个线程读共享资源其实也没有问题。
  • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题。
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区。

例如下面代码中的临界区:

static int counter = 0;
 
static void increment()
// 临界区
{
    counter++;
}
 
static void decrement()
// 临界区
{
    counter--;
}

竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。

4.2 synchronized 解决方案

应用之互斥

为了避免临界区发生竞态条件,有多种手段:

  • 阻塞式的解决方案:synchronized、Lock
  • 非阻塞式的解决方案:原子变量

这里使用阻塞式的 synchronized,即俗称的【对象锁】。它采用互斥的方式,让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个对象锁时就会阻塞住。这样就能保证持有锁的线程可以安全地执行临界区内的代码,不用担心线程上下文切换。

注意:虽然 Java 中互斥和同步都可以用 synchronized 完成,但它们有区别:

  • 互斥是保证临界区同一时刻只能有一个线程执行;
  • 同步是由于线程执行的先后顺序不同,需要一个线程等待其它线程运行到某个点。

synchronized 语法

synchronized(对象) // 线程1, 线程2(blocked)
{
    临界区
}

用 synchronized 改造前面的计数器:

static int counter = 0;
static final Object room = new Object();
 
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            synchronized (room) {
                counter++;
            }
        }
    }, "t1");
 
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            synchronized (room) {
                counter--;
            }
        }
    }, "t2");
 
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    log.debug("{}",counter);
}

用房间来类比

synchronized(对象) 中的对象可以想象为一个房间(room),有唯一入口(门),房间一次只能进入一人,线程 t1、t2 是两个人:

  • t1 执行到 synchronized(room) 时,好比进入房间并锁住门、拿走钥匙,在门内执行 counter++。
  • 这时 t2 运行到 synchronized(room),发现门被锁住,只能在门外等待,发生上下文切换,阻塞住了。
  • 这中间即使 t1 的 CPU 时间片用完被踢出了门外(不是锁住对象就能一直执行下去),门仍然锁着,钥匙还在 t1 手里,t2 仍处于阻塞状态;只有 t1 再次获得时间片时才能开门进入。
  • 当 t1 执行完 synchronized 块内的代码,才会出门、解开门锁、唤醒 t2 把钥匙交给它,t2 才能进入房间执行 counter--。

思考

synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外不可分割,不会被线程切换所打断。下面几个问题可以加深理解:

  • 如果把 synchronized(obj) 放在 for 循环的外面,如何理解?—— 整个循环成为一个大的原子操作。
  • 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样?—— 两把不同的锁,互不阻塞,临界区仍然有竞态条件。
  • 如果 t1 synchronized(obj) 而 t2 没有加会怎么样?—— t2 不参与竞争,相当于翻窗户进屋,锁保护不了它。

面向对象改进

把需要保护的共享变量放入一个类中:

class Room {
    int value = 0;
 
    public void increment() {
        synchronized (this) {
            value++;
        }
    }
 
    public void decrement() {
        synchronized (this) {
            value--;
        }
    }
 
    public int get() {
        synchronized (this) {
            return value;
        }
    }
}
 
@Slf4j
public class Test1 {
 
    public static void main(String[] args) throws InterruptedException {
        Room room = new Room();
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.increment();
            }
        }, "t1");
 
        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                room.decrement();
            }
        }, "t2");
        t1.start();
        t2.start();
 
        t1.join();
        t2.join();
        log.debug("count: {}" , room.get());
    }
}

4.3 方法上的 synchronized

成员方法上的 synchronized

class Test{
    public synchronized void test() {
 
    }
}

等价于:

class Test{
    public void test() {
        synchronized(this) {
 
        }
    }
}

静态方法上的 synchronized

class Test{
    public synchronized static void test() {
 
    }
}

等价于:

class Test{
    public static void test() {
        synchronized(Test.class) {
 
        }
    }
}

不加 synchronized 的方法就好比不遵守规则的人,不去老实排队(好比翻窗户进去的),锁对它不起作用。

所谓的「线程八锁」

线程八锁其实就是考察 synchronized 锁住的是哪个对象。

情况 1:结果为 12 或 21(两个方法都锁 this,同一对象互斥)

@Slf4j(topic = "c.Number")
class Number{
    public synchronized void a() {
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
 
public static void main(String[] args) {
    Number n1 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n1.b(); }).start();
}

情况 2:1s 后 12,或 2 1s 后 1

@Slf4j(topic = "c.Number")
class Number{
    public synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
 
public static void main(String[] args) {
    Number n1 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n1.b(); }).start();
}

情况 3:3 1s 12,或 23 1s 1,或 32 1s 1(c 不加锁,不受影响)

class Number{
    public synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
    public void c() {
        log.debug("3");
    }
}
 
public static void main(String[] args) {
    Number n1 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n1.b(); }).start();
    new Thread(()->{ n1.c(); }).start();
}

情况 4:2 1s 后 1(两个线程锁的是不同对象)

@Slf4j(topic = "c.Number")
class Number{
    public synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
 
public static void main(String[] args) {
    Number n1 = new Number();
    Number n2 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n2.b(); }).start();
}

情况 5:2 1s 后 1(静态方法锁 Class 对象,成员方法锁 this,不是同一把锁)

@Slf4j(topic = "c.Number")
class Number{
    public static synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
 
public static void main(String[] args) {
    Number n1 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n1.b(); }).start();
}

情况 6:1s 后 12,或 2 1s 后 1(都是静态方法,锁同一个 Class 对象)

@Slf4j(topic = "c.Number")
class Number{
    public static synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public static synchronized void b() {
        log.debug("2");
    }
}
 
public static void main(String[] args) {
    Number n1 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n1.b(); }).start();
}

情况 7:2 1s 后 1(a 锁 Class,b 锁 n2 这个实例)

@Slf4j(topic = "c.Number")
class Number{
    public static synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
 
public static void main(String[] args) {
    Number n1 = new Number();
    Number n2 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n2.b(); }).start();
}

情况 8:1s 后 12,或 2 1s 后 1(都是静态方法,即使是两个实例,锁的仍是同一个 Class 对象)

@Slf4j(topic = "c.Number")
class Number{
    public static synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public static synchronized void b() {
        log.debug("2");
    }
}
 
public static void main(String[] args) {
    Number n1 = new Number();
    Number n2 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n2.b(); }).start();
}

4.4 变量的线程安全分析

成员变量和静态变量是否线程安全?

  • 如果它们没有共享,则线程安全。
  • 如果被共享了,根据状态是否能改变分两种情况:
    • 如果只有读操作,则线程安全;
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全。

局部变量是否线程安全?

  • 局部变量是线程安全的。
  • 但局部变量引用的对象则未必:
    • 如果该对象没有逃离方法的作用范围,它是线程安全的;
    • 如果该对象逃离了方法的作用范围,需要考虑线程安全。

局部变量线程安全分析

每个线程调用 test1() 方法时,局部变量 i 会在每个线程的栈帧内存中被创建多份,因此不存在共享:

public static void test1() {
    int i = 10;
    i++;
}

对应的字节码也能看出 i 是方法栈帧中的局部变量:

public static void test1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=1, args_size=0
         0: bipush        10
         2: istore_0
         3: iinc          0, 1
         6: return
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            3       4     0     i   I

局部变量 i 在 Thread-0 与 Thread-1 的栈帧中各有一份

成员变量的例子

下面的代码中 list 是成员变量,存在线程安全问题:

class ThreadUnsafe {
    ArrayList<String> list = new ArrayList<>();
    public void method1(int loopNumber) {
        for (int i = 0; i < loopNumber; i++) {
            // { 临界区, 会产生竞态条件
            method2();
            method3();
            // } 临界区
        }
    }
 
    private void method2() {
        list.add("1");
    }
 
    private void method3() {
        list.remove(0);
    }
}
 
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
    ThreadUnsafe test = new ThreadUnsafe();
    for (int i = 0; i < THREAD_NUMBER; i++) {
        new Thread(() -> {
            test.method1(LOOP_NUMBER);
        }, "Thread" + i).start();
    }
}

其中一种情况是,线程 2 还未 add,线程 1 就 remove,会报错:

Exception in thread "Thread1" java.lang.IndexOutOfBoundsException: Index: 0, Size: 0
    at java.util.ArrayList.rangeCheck(ArrayList.java:657)
    at java.util.ArrayList.remove(ArrayList.java:496)
    ...

分析:无论哪个线程中的 method2,引用的都是同一个对象中的 list 成员变量;method3 与 method2 相同。

两个线程的 method2/method3 栈帧都指向堆中同一个 list 对象

将 list 修改为局部变量

class ThreadSafe {
    public final void method1(int loopNumber) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            method2(list);
            method3(list);
        }
    }
 
    private void method2(ArrayList<String> list) {
        list.add("1");
    }
 
    private void method3(ArrayList<String> list) {
        list.remove(0);
    }
}

这样就不会有上述问题了:

  • list 是局部变量,每个线程调用时都会创建不同实例,没有共享;
  • method2 的参数从 method1 传递过来,与 method1 引用同一个对象;
  • method3 的参数分析与 method2 相同。

局部变量 list 在堆中是两个对象,每个线程引用各自的 list

访问修饰符带来的思考

如果把 method2 和 method3 修改为 public 会不会有线程安全问题?

  • 情况 1:有其它线程直接调用 method2 和 method3;
  • 情况 2:在情况 1 的基础上,为 ThreadSafe 添加子类,子类覆盖 method2 或 method3:
class ThreadSafe {
    public final void method1(int loopNumber) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            method2(list);
            method3(list);
        }
    }
 
    private void method2(ArrayList<String> list) {
        list.add("1");
    }
 
    private void method3(ArrayList<String> list) {
        list.remove(0);
    }
}
 
class ThreadSafeSubClass extends ThreadSafe{
    @Override
    public void method3(ArrayList<String> list) {
        new Thread(() -> {
            list.remove(0);
        }).start();
    }
}

从这个例子可以体会 private 或 final 提供「安全」的意义所在,这正是开闭原则中的【闭】:方法私有或用 final 禁止覆盖,可以避免子类把局部变量暴露给其它线程。

常见线程安全类

常见的线程安全类有:

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类

这里说它们线程安全,是指多个线程调用它们同一个实例的某个方法时是线程安全的,也可以理解为:它们的每个方法是原子的。但要注意,多个方法的组合不是原子的。

单个方法是安全的:

Hashtable table = new Hashtable();
 
new Thread(()->{
    table.put("key", "value1");
}).start();
 
new Thread(()->{
    table.put("key", "value2");
}).start();

线程安全类方法的组合

分析下面代码是否线程安全?

Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
    table.put("key", value);
}

虽然 get 和 put 各自都是原子的,但两个线程可能都先 get 到 null,再分别 put,组合起来仍然存在竞态条件。

不可变类的线程安全性

String、Integer 等都是不可变类,其内部状态不可改变,因此它们的方法都是线程安全的。有同学会疑问,String 有 replace、substring 等方法「可以」改变值啊?实际上这些方法返回的是一个新的对象,并没有修改原来的对象。

public class Immutable{
  private int value = 0;
 
  public Immutable(int value){
    this.value = value;
  }
 
  public int getValue(){
    return this.value;
  }
}

如果想增加一个 add 方法,应该这样写:

public class Immutable{
  private int value = 0;
 
  public Immutable(int value){
    this.value = value;
  }
 
  public int getValue(){
    return this.value;
  }
 
  public Immutable add(int v){
    return new Immutable(this.value + v);
  }
}

实例分析

例 1:成员变量是否安全?

public class MyServlet extends HttpServlet {
    // 是否安全?
    Map<String,Object> map = new HashMap<>();
    // 是否安全?
    String S1 = "...";
    // 是否安全?
    final String S2 = "...";
    // 是否安全?
    Date D1 = new Date();
    // 是否安全?
    final Date D2 = new Date();
 
    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        // 使用上述变量
    }
}
  • map 不安全(HashMap 可变且有读写);
  • S1、S2 安全(String 不可变);
  • D1 不安全(Date 对象可变);
  • D2 仍然不安全,final 只保证引用不能再指向别的对象,但 Date 内部的日期状态可以被修改。

例 2:Service 中的计数

public class MyServlet extends HttpServlet {
    // 是否安全?
    private UserService userService = new UserServiceImpl();
 
    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        userService.update(...);
    }
}
 
public class UserServiceImpl implements UserService {
    // 记录调用次数
    private int count = 0;
 
    public void update() {
        // ...
        count++;
    }
}

不安全。Servlet 单例、Service 单例,count 是被多线程读写的成员变量。

例 3:切面中记录开始时间

@Aspect
@Component
public class MyAspect {
    // 是否安全?
    private long start = 0L;
 
    @Before("execution(* *(..))")
    public void before() {
        start = System.nanoTime();
    }
 
    @After("execution(* *(..))")
    public void after() {
        long end = System.nanoTime();
        System.out.println("cost time:" + (end-start));
    }
}

不安全。单例切面中的 start 被多个请求线程共享,before/after 之间可能被其它线程改写。

例 4:DAO 中使用局部 Connection

public class MyServlet extends HttpServlet {
    // 是否安全
    private UserService userService = new UserServiceImpl();
 
    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        userService.update(...);
    }
}
 
public class UserServiceImpl implements UserService {
    // 是否安全
    private UserDao userDao = new UserDaoImpl();
 
    public void update() {
        userDao.update();
    }
}
 
public class UserDaoImpl implements UserDao {
    public void update() {
        String sql = "update user set password = ? where username = ?";
        // 是否安全
        try (Connection conn = DriverManager.getConnection("","","")){
            // ...
        } catch (Exception e) {
            // ...
        }
    }
}

安全。conn 是方法内的局部变量,每个线程各有一份。

例 5:DAO 中 Connection 是成员变量

public class UserDaoImpl implements UserDao {
    // 是否安全
    private Connection conn = null;
    public void update() throws SQLException {
        String sql = "update user set password = ? where username = ?";
        conn = DriverManager.getConnection("","","");
        // ...
        conn.close();
    }
}

不安全。conn 是成员变量,被多个线程共享。

例 6:每次调用 new 一个 DAO,以及把局部对象交给外星方法

public class UserServiceImpl implements UserService {
    public void update() {
        UserDao userDao = new UserDaoImpl();
        userDao.update();
    }
}

UserDaoImpl 每次在方法内创建,是线程安全的。再看下面的抽象类:

public abstract class Test {
 
    public void bar() {
        // 是否安全
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        foo(sdf);
    }
 
    public abstract foo(SimpleDateFormat sdf);
 
 
    public static void main(String[] args) {
        new Test().bar();
    }
}

sdf 本来是局部变量,但 foo 的行为是不确定的。

例 7:外星方法导致对象逃离

public void foo(SimpleDateFormat sdf) {
    String dateStr = "1999-10-11 00:00:00";
    for (int i = 0; i < 20; i++) {
        new Thread(() -> {
            try {
                sdf.parse(dateStr);
            } catch (ParseException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

foo 的实现把 sdf 传递给了 20 个线程,导致局部对象逃离了方法作用范围。这种行为不确定的方法被称为外星方法,SimpleDateFormat 本身又不是线程安全的。可以对比体会 JDK 中 String 类为什么被设计为 final 且不可变。

例 8:在 Integer 对象上加锁

private static Integer i = 0;
public static void main(String[] args) throws InterruptedException {
    List<Thread> list = new ArrayList<>();
    for (int j = 0; j < 2; j++) {
        Thread thread = new Thread(() -> {
            for (int k = 0; k < 5000; k++) {
                synchronized (i) {
                    i++;
                }
            }
        }, "" + j);
        list.add(thread);
    }
    list.stream().forEach(t -> t.start());
    list.stream().forEach(t -> {
        try {
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    log.debug("{}", i);
}

不安全。Integer 是不可变类,i++ 实际上等价于 i = Integer.valueOf(i.intValue() + 1),会生成新对象,导致 synchronized(i) 每次锁住的都不是同一个对象。

4.5 习题

卖票练习

测试下面代码是否存在线程安全问题,并尝试改正:

public class ExerciseSell {
    public static void main(String[] args) {
        TicketWindow ticketWindow = new TicketWindow(2000);
        List<Thread> list = new ArrayList<>();
        // 用来存储买出去多少张票
        List<Integer> sellCount = new Vector<>();
        for (int i = 0; i < 2000; i++) {
            Thread t = new Thread(() -> {
                // 分析这里的竞态条件
                int count = ticketWindow.sell(randomAmount());
                sellCount.add(count);
            });
            list.add(t);
            t.start();
        }
        list.forEach((t) -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        // 买出去的票求和
        log.debug("selled count:{}",sellCount.stream().mapToInt(c -> c).sum());
        // 剩余票数
        log.debug("remainder count:{}", ticketWindow.getCount());
    }
    // Random 为线程安全
    static Random random = new Random();
    // 随机 1~5
    public static int randomAmount() {
        return random.nextInt(5) + 1;
    }
}
 
class TicketWindow {
    private int count;
 
    public TicketWindow(int count) {
        this.count = count;
    }
 
    public int getCount() {
        return count;
    }
 
    public int sell(int amount) {
        if (this.count >= amount) {
            this.count -= amount;
            return amount;
        } else {
            return 0;
        }
    }
}

sell 方法中对共享变量 count 的「判断 - 扣减」是临界区,多线程下可能超卖,需要给 sell(以及读取剩余票数的 getCount)加上 synchronized。

另外,把 new Vector<>() 换成普通的 new ArrayList<>() 行不行?不行,sellCount.add 也会被多个线程并发调用,ArrayList 自身线程不安全,仍应使用 Vector 等线程安全集合或加锁。

转账练习

测试下面代码是否存在线程安全问题,并尝试改正:

public class ExerciseTransfer {
    public static void main(String[] args) throws InterruptedException {
        Account a = new Account(1000);
        Account b = new Account(1000);
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                a.transfer(b, randomAmount());
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                b.transfer(a, randomAmount());
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 查看转账2000次后的总金额
        log.debug("total:{}",(a.getMoney() + b.getMoney()));
    }
    // Random 为线程安全
    static Random random = new Random();
    // 随机 1~100
    public static int randomAmount() {
        return random.nextInt(100) +1;
    }
}
 
class Account {
    private int money;
 
    public Account(int money) {
        this.money = money;
    }
 
    public int getMoney() {
        return money;
    }
 
    public void setMoney(int money) {
        this.money = money;
    }
 
    public void transfer(Account target, int amount) {
        if (this.money > amount) {
            this.setMoney(this.getMoney() - amount);
            target.setMoney(target.getMoney() + amount);
        }
    }
}

这样改正行不行?

public synchronized void transfer(Account target, int amount) {
    if (this.money > amount) {
        this.setMoney(this.getMoney() - amount);
        target.setMoney(target.getMoney() + amount);
    }
}

不行。成员方法加 synchronized 锁住的是 this,a 线程锁的是 a 对象,b 线程锁的是 b 对象,两把锁互不阻塞,临界区仍然会交错。要让所有转账线程互斥,需要一把共享的锁,例如锁住 Account 类对象:

public void transfer(Account target, int amount) {
    synchronized (Account.class) {
        if (this.money > amount) {
            this.setMoney(this.getMoney() - amount);
            target.setMoney(target.getMoney() + amount);
        }
    }
}

4.6 Monitor 概念

Java 对象头

以 32 位虚拟机为例,普通对象的对象头由 Mark Word 和 Klass Word 组成:

|--------------------------------------------------------------|
|                     Object Header (64 bits)                  |
|------------------------------------|-------------------------|
|        Mark Word (32 bits)         |    Klass Word (32 bits) |
|------------------------------------|-------------------------|

数组对象的对象头还多一个 array length 字段:

|---------------------------------------------------------------------------------|
|                                 Object Header (96 bits)                         |
|--------------------------------|-----------------------|------------------------|
|        Mark Word(32bits)       |    Klass Word(32bits) |  array length(32bits)  |
|--------------------------------|-----------------------|------------------------|

其中 Mark Word 的结构为:

|-------------------------------------------------------|--------------------|
|                  Mark Word (32 bits)                  |       State        |
|-------------------------------------------------------|--------------------|
|  hashcode:25         | age:4 | biased_lock:0 | 01     |       Normal       |
|-------------------------------------------------------|--------------------|
|  thread:23 | epoch:2 | age:4 | biased_lock:1 | 01     |       Biased       |
|-------------------------------------------------------|--------------------|
|               ptr_to_lock_record:30          | 00     | Lightweight Locked |
|-------------------------------------------------------|--------------------|
|               ptr_to_heavyweight_monitor:30  | 10     | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
|                                              | 11     |    Marked for GC   |
|-------------------------------------------------------|--------------------|

64 位虚拟机的 Mark Word 如下:

|--------------------------------------------------------------------|--------------------|
|                        Mark Word (64 bits)                         |       State        |
|--------------------------------------------------------------------|--------------------|
| unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01    |       Normal       |
|--------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2     | unused:1 | age:4 | biased_lock:1 | 01    |       Biased       |
|--------------------------------------------------------------------|--------------------|
|             ptr_to_lock_record:62                          | 00    | Lightweight Locked |
|--------------------------------------------------------------------|--------------------|
|             ptr_to_heavyweight_monitor:62                  | 10    | Heavyweight Locked |
|--------------------------------------------------------------------|--------------------|
|                                                            | 11    |    Marked for GC   |
|--------------------------------------------------------------------|--------------------|

可以看到,Mark Word 在不同状态下会记录哈希码、GC 分代年龄、偏向线程 ID、锁记录指针或重量级 Monitor 指针等信息。

Monitor 的结构

Monitor 被称为监视器或管程。每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级),该对象头的 Mark Word 中就被设置为指向 Monitor 对象的指针。

Monitor 的结构可以用「房间」来类比:

Monitor 结构:WaitSet 休息室、Owner 主人房与 EntryList 阻塞队列

  • Owner:同一时刻只有一个线程能成为 Owner,持有对象锁,在房间内执行临界区代码;
  • EntryList:竞争锁失败的线程进入 EntryList 阻塞等待;
  • WaitSet:曾经获得过锁、但条件不满足而调用 wait 的线程进入 WaitSet 等待(下一篇详解)。

synchronized 的工作原理

  • 线程执行 synchronized 代码块时,先尝试让自己成为 Monitor 的 Owner:
    • 成功,就持有 Monitor 继续执行临界区代码;
    • 失败(Monitor 的 Owner 已经是别的线程),就进入 EntryList 变成 BLOCKED 状态。
  • Owner 线程执行完同步代码块后释放锁,唤醒 EntryList 中阻塞的线程重新竞争。
  • wait/notify 的等待与唤醒则发生在 WaitSet 与 EntryList 之间。

synchronized 优化初步

早期 synchronized 直接使用 Monitor(重量级锁),成本较高。JDK 6 对 synchronized 做了大量优化:偏向锁、轻量级锁、重量级锁会随着竞争情况逐步升级。仍用小故事来理解:

故事角色:

  • 老王 —— JVM
  • 小南、小女 —— 线程
  • 房间 —— 对象
  • 房间门上的防盗锁 —— Monitor(重量级锁)
  • 房间门上挂的小南书包 —— 轻量级锁
  • 房间门上刻上小南大名 —— 偏向锁
  • 批量重刻名 —— 一个类的偏向锁撤销达到 20 次阈值后的批量重偏向
  • 不能刻名字 —— 批量撤销该类对象的偏向锁,设置该类不可偏向

故事过程:

  1. 小南最初用的是防盗锁(重量级锁),上下文切换时锁住门,即使离开了别人也进不来,工作是安全的,但上锁、解锁以及操作系统层面的阻塞切换开销大。
  2. 很多时候根本没人竞争房间(小南白天用、小女晚上用,时间错开),每次都上锁太麻烦。于是约定谁用房间谁把书包挂在门口(轻量级锁):进门先翻翻书包确认是不是自己的,是自己的就直接进,省去系统级加锁解锁;万一书包不是自己的,就在门外等,并通知对方下次改用锁门的方式(锁膨胀)。
  3. 后来小女很长时间不用房间,小南挂书包、翻书包还是觉得麻烦,干脆在门上刻上自己的名字(偏向锁):下次来只要名字还在,说明没人打扰,可以直接使用;如果这期间有别人要用房间,就由使用者擦掉名字,升级为挂书包(轻量级锁)的方式。
  4. 小南在 20 个房间都刻了名字,放假回来的小女要一个个擦掉、升级,成本很高。于是 JVM 提供了批量重偏向:让小女可以直接在门上重刻名字;当刻名/擦名现象越来越频繁,则批量撤销该类所有对象的偏向锁,并设置该类不可偏向,之后只能走轻量级锁流程。

三种锁的升级路径与 Mark Word 的状态一一对应:Biased(偏向锁)→ Lightweight Locked(轻量级锁)→ Heavyweight Locked(重量级锁,指向 Monitor)。锁只能升级不能降级,具体的升级与优化细节在 synchronized 进阶原理中还会深入讲解。