JUC并发编程第二章(共享模型之管程)


JUC并发编程 共享模型之管程

1.1 共享带来的问题

1.1.1 小故事:

老王(操作系统)有一个功能强大的算盘(CPU),现在想把它租出去,赚一点外快。

小南、小女(线程)来使用这个算盘来进行一些计算,并按照时间给老王支付费用。但小南不能一天24小时使用算盘,他经常要小憩一会(sleep),又或是去吃饭上厕所(阻塞 io 操作),有时还需要一根烟,没烟时思路全无(wait)这些情况统称为(阻塞

在这些时候,算盘没利用起来(不能收钱了),老王觉得有点不划算,另外,小女也想用用算盘,如果总是小南占着算盘,让小女觉得不公平于是,老王灵机一动,想了个办法 [ 让他们每人用一会,轮流使用算盘 ],这样,当小南阻塞的时候,算盘可以分给小女使用,不会浪费,反之亦然。

最近执行的计算比较复杂,需要存储一些中间结果,而学生们的脑容量(工作内存)不够,所以老王申请了一个笔记本(主存),把一些中间结果先记在本上,计算流程是这样的:

小南只能做加法,小女只能做减法。

但是由于分时系统,有一天还是发生了事故:小南刚读取了初始值 0 做了个 +1 运算,还没来得及写回结果老王说 [ 小南,你的时间到了,该别人了,记住结果走吧 ],于是小南念叨着 [ 结果是1,结果是1…] 不甘心地到一边待着去了(上下文切换);老王说 [ 小女,该你了 ],小女看到了笔记本上还写着 0 做了一个 -1 运算,将结果 -1 写入笔记本,这时小女的时间也用完了,老王又叫醒了小南:[小南,把你上次的题目算完吧],小南将他脑海中的结果 1 写入了笔记本。

上述描述

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

这就是共享资源由于分时系统产生的问题。

1.1.2 Java的体现

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

@Slf4j
public class Main {
    static int counter = 0;
    public static void main(String[] args) throws InterruptedException {
        //线程t1做5000次加法
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter++;
            }
        }, "t1");
        //线程t2做5000次减法
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter--;
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("{}",counter);
    }

}

输出:

10:24:33.381 [main] DEBUG com.example.Main - 731

可见结果并不为0。

1.1.3 问题分析

以上的结果可能是正数、负数、零。为什么呢?因为 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 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

sequenceDiagram
线程1->>static i: getstatic i 读取 0
线程1->>线程1: iconst_1 准备常数 1
线程1->>线程1: iadd 加法,线程内 i = 1
线程1->>static i: putstatic i 写入 1
static i->>线程1: getstatic i 读取 1
线程1->>线程1: iconst_1 准备常数 1
线程1->>线程1: iadd 加法,线程内 i = 1
线程1->>static i: putstatic i 写入 0

但多线程下这 8 行代码可能交错运行,出现负数的情况:

sequenceDiagram
static i->>线程2: getstatic i 读取 0
线程2->>线程2: iconst_1 准备常数 1
线程2->>线程2: isub 减法, 线程内 i = -1
线程2-->>线程1: 上下文切换
static i->>线程1: getstatic i 读取 0
线程1->>线程1: iconst_1 准备常数 1
线程1->>线程1: iadd 加法, 线程内 i = 1
线程1->>static i: putstatic i 写入 1
线程1-->>线程2: 上下文切换
线程2->>static i: putstatic i 写入 -1

出现正数的情况:

sequenceDiagram
static i->>线程1: getstatic i 读取 0
线程1->>线程1: iconst_1 准备常数 1
线程1->>线程1: iadd 加法, 线程内 i = 1
线程1-->>线程2: 上下文切换
static i->>线程2: getstatic i 读取 0
线程2->>线程2: iconst_1 准备常数 1
线程2->>线程2: isub 减法, 线程内 i = -1
线程2->>static i: putstatic i 写入 -1
线程2-->>线程1: 上下文切换
线程1->>static i: putstatic i 写入 1

1.1.4 临界区 Critical Section

  • 一个程序运行多个线程本身是没有问题的

  • 问题出在多个线程访问共享资源

    • 多个线程读共享资源其实也没有问题

    • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题

  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

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

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

具体说来:当多个线程同时访问共享资源时,可能会导致数据不一致性和竞态条件的问题。为了避免这些问题,需要将对共享资源的访问限制在同一时间只有一个线程进行,这就是临界区的概念

临界区是一个在多线程环境中用于保护共享资源的代码段或代码块,以确保在同一时刻只有一个线程能够执行其中的代码。这种机制可以通过同步(synchronization)来实现,通常使用同步机制来确保只有一个线程可以进入临界区,从而防止多线程并发访问引发的问题。

在Java中,临界区可以使用以下方法来实现:

  1. synchronized 关键字: 使用 synchronized 关键字可以将方法或代码块标记为临界区,从而确保同一时间只有一个线程可以执行其中的代码。
public synchronized void criticalSection() {
    // 临界区代码
}
  1. ReentrantLock 类: 这是 Java.util.concurrent 包中提供的一个用于同步的类,它允许更灵活地控制临界区。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class SharedResource {
    private int sharedValue = 0;
    private Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            sharedValue++;
        } finally {
            lock.unlock();
        }
    }
}
  1. Semaphore 和 CountDownLatch: 这些类也可以用于控制临界区的访问,虽然它们的主要目的是实现不同的线程协调和同步策略。

使用临界区机制可以确保共享资源在任何时候只能由一个线程修改或访问,从而避免了数据竞争和不一致性的问题。然而,需要注意以下几点:

  • 过多的临界区可能会导致性能问题,因为只有一个线程可以执行临界区内的代码,其他线程将被阻塞。
  • 错误的使用临界区可能导致死锁,即多个线程相互等待对方释放锁,从而无法继续执行。
  • 临界区应该保持尽可能小的范围,只包含必要的操作,以最小化阻塞其他线程的时间。

综上所述,临界区是多线程编程中用于保护共享资源的重要概念,通过适当地使用同步机制,可以确保线程安全和数据一致性。

1.1.5 竞态条件 Race Condition

竞态条件(Race Condition)是多线程编程中一种常见的并发问题,指的是多个线程对共享资源进行读写操作时,由于执行顺序不确定而导致的不稳定或不正确的结果。竞态条件可能会导致程序出现意料之外的行为,甚至引发严重的错误。

竞态条件的产生通常遵循以下模式:

  1. 多线程环境: 两个或更多的线程同时访问同一个共享资源。

  2. 共享资源的写操作: 至少有一个线程对共享资源进行写操作,可能修改其值或状态。

  3. 非原子性操作: 共享资源的访问或修改操作不是原子性的,可能由多个步骤组成。

  4. 未同步操作: 没有适当的同步机制来保护共享资源,使得线程无法正确地协调其访问。

由于竞态条件的存在,不同线程之间的操作顺序可能是不确定的,导致以下问题之一:

  1. 数据不一致性: 多个线程同时读写共享资源可能导致其值处于未定义状态,违反了预期的业务逻辑。

  2. 逻辑错误: 竞态条件可能导致代码在特定情况下产生错误的结果,即使在单线程情况下是正确的。

  3. 安全性问题: 如果一个线程正在写共享资源,而另一个线程正在读取该资源,读取线程可能会看到部分更新的数据,从而导致不一致的结果。

为了避免竞态条件,可以采取以下措施:

  1. 使用锁: 使用同步机制(如 synchronizedReentrantLock)来保护共享资源,确保一次只有一个线程可以访问或修改。

  2. 使用原子操作: 使用原子操作可以确保某些操作是不可分割的,从而避免多线程之间的交错。

  3. 使用线程安全的数据结构: 在并发环境中使用线程安全的数据结构,如 ConcurrentHashMap,可以避免竞态条件。

  4. 合理的并发设计: 尽量避免共享资源,通过设计减少线程之间的依赖,从而减少竞态条件的机会。

  5. 进行测试和代码审查: 在多线程代码中进行全面的测试和代码审查,以识别和解决潜在的竞态条件问题。

总之,竞态条件是多线程编程中需要特别注意的问题,合适的同步和并发控制策略可以帮助避免这些问题,并确保程序在多线程环境下的正确性和稳定性。

1.2 synchronized 解决方案

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 阻塞式的解决方案:synchronizedLock
  • 非阻塞式的解决方案:原子变量

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

注意

虽然 java 中互斥同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:

  • 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
  • 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

synchronized 是 Java 中用于实现同步的关键字,它可以用于方法或代码块,用来确保在同一时间内只有一个线程可以执行被 synchronized 修饰的代码段。synchronized 主要用于解决多线程环境下的并发访问共享资源的问题,避免竞态条件和数据不一致性。

synchronized 可以应用在以下两种方式:

  1. 方法级别的同步: 可以使用 synchronized 关键字修饰整个方法,从而保证同一时间只有一个线程可以执行这个方法。
public synchronized void synchronizedMethod() {
    // 这里是同步代码块
}
  1. 代码块级别的同步: 可以使用 synchronized 关键字修饰一个代码块,只对该代码块内的内容进行同步,而不是整个方法。
public void someMethod() {
    synchronized (lockObject) {
        // 这里是同步代码块
    }
}

要注意以下几点关于 synchronized 的使用:

  • 锁对象:方法级别的同步中,锁对象是当前对象(即调用方法的实例)。在代码块级别的同步中,可以自行选择一个对象作为锁,多个线程共享同一个锁对象时才能起到同步作用。

  • 互斥性: 一旦一个线程进入 synchronized 代码块,其他试图进入同一个代码块的线程将被阻塞,直到进入的线程执行完毕并释放锁。

  • 可重入性: 同一线程可以多次获取同一个锁,而不会出现死锁。这种机制称为可重入性。

  • 性能开销: 使用 synchronized 会引入一定的性能开销,因为线程在进入和退出同步块时需要进行额外的操作。

  • 静态方法: 静态方法的同步作用域是整个类,因为静态方法是属于类而不是实例。

除了基本的 synchronized 关键字,Java 还提供了其他同步工具,如 ReentrantLockSemaphoreCondition 等,用于更灵活地控制同步。

总之,synchronized 是 Java 中用于解决多线程并发问题的一种基本机制,通过确保同一时间只有一个线程可以执行同步代码块,可以避免竞态条件和数据不一致性的问题。

解决之前的问题:

@Slf4j
public class Main {
    static int counter = 0;
    //创建一个共享对象作为锁对象
    static Object lock = new Object();
    public static void main(String[] args) throws InterruptedException {
        //线程t1做5000次加法
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (lock){
                    counter++;
                }
            }
        }, "t1");
        //线程t2做5000次减法
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (lock){
                    counter--;
                }
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("{}",counter);
    }

}

输出:

07:12:37.557 [main] DEBUG com.nxz.Main - 0

可以测试:无论执行多少次都是0。

示意图

你可以做这样的类比:

  • synchronized(对象) 中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人进行计算,线程 t1,t2 想象成两个人
  • 当线程 t1 执行到 synchronized(room) 时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行count++ 代码
  • 这时候如果 t2 也运行到了 synchronized(room) 时,它发现门被锁住了,只能在门外等待(阻塞)。
  • 这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦),这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才能开门进入
  • 当 t1 执行完 synchronized{} 块内的代码,这时候才会从 obj 房间出来并解开门上的锁,**唤醒 **t2 线程把钥匙给他。t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的 count– 代码

总结:

如果一个线程获取到了锁,即使他的时间片用完了,他也不会释放锁,而是等待下一次分到时间片的时候继续执行,同时,当有一个线程获取到锁并且在执行同步代码块(方法)中的时候,其他的线程都是处于阻塞的状态;当某个线程处理完同步代码块(方法)的时候,它还必须要去唤醒等待中的线程,让他们来竞争锁。

用图来表示:

sequenceDiagram
线程2->>锁对象: 尝试获取锁
Note over 线程2,锁对象: 拥有锁
static i->>线程2: getstatic i 读取 0
线程2->>线程2: iconst_1 准备常数 1
线程2->>线程2: isub 减法, 线程内 i = -1
线程2-->>线程1: 上下文切换
线程1-x锁对象: 尝试获取锁,被阻塞(BLOCKED)
线程1-->>线程2: 上下文切换
线程2->>static i: putstatic i 写入 -1
Note over 线程2,锁对象: 拥有锁
线程2->>锁对象: 释放锁,并唤醒阻塞的线程
static i->>线程1: getstatic i 读取 -1
线程1->>线程1: iconst_1 准备常数 1
线程1->>线程1: iadd 加法, 线程内 i = 0
线程1->>static i: putstatic i 写入 0
Note over 线程1,锁对象: 拥有锁
线程1->>锁对象: 释放锁

思考:

synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。

为了加深理解,请思考下面的问题:

  • 如果把 synchronized(obj) 放在 for 循环的外面,如何理解?

    //线程t1做5000次加法
    Thread t1 = new Thread(() -> {
        synchronized (lock){
            counter++;
            for (int i = 0; i < 5000; i++) {
                counter++;
            }
        }
    }, "t1");
    //线程t2做5000次减法
    Thread t2 = new Thread(() -> {
        synchronized (lock){
            for (int i = 0; i < 5000; i++) {
                counter--;
            }
        }
    }, "t2");
    

    其实效果是一样的,只是这里锁粒度的问题,依然是原子操作

  • 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?

    不可以,必须要获取同意把锁才可以,上面的相当于两把锁了。

  • 如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?

    这里依旧是不行的,这里如果t2没有加锁,当t1发生上下文切换的时候,会金进入t2去执行,从而也会导致竞态条件。

面向对象的改进:

@Slf4j
public class Main {
    static int counter = 0;
    //创建一个共享对象作为锁对象
    static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        Room room = new Room();
        //线程t1做5000次加法
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                room.increment();
            }
        }, "t1");
        //线程t2做5000次减法
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                room.decrement();
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("{}", counter);
    }

}

class Room {
    private int counter = 0;

    public void increment() {
        synchronized (this) {
            counter++;
        }
    }

    public void decrement() {
        synchronized (this) {
            counter--;
        }
    }
    //对于结果的获取我认为不用加锁
    public int getCounter() {
        return counter;
    }
}

1.3 方法上的 synchronized

对于普通方法:

public void increment() {
    synchronized (this) {
        counter++;
    }
}

//等价于
public synchronized void increment() {
    counter++;
}

对于静态方法

class Test{
    public synchronized static void test() {
    }
}
//等价于
class Test{
    public static void test() {
        synchronized(Test.class) {
            
        }
    }
}

因为静态变量不是针对与某个实例的,而是针对于整个类,所以这里的锁对象也必须是整个类(Xxx.class),而普通的成员方法或者是实例方法锁对象为this—当前对象。

1.4 所谓的“线程八锁”

其实就是考察 synchronized 锁住的是哪个对象

1.4.1 情况1

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        Number n1 = new Number();
        new Thread(()->{ n1.a(); }).start(); //线程1
        new Thread(()->{ n1.b(); }).start(); //线程2
    }

}
@Slf4j
class Number{
    public synchronized void a() {
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}

分析:

Number中的synchronized都是修饰在了实例方法上面,所以锁对象都为当前对象实例,所以对于main方法中的代码,由于只new了一个Number实例,并且二者都是用的同一个实例,所以两个线程是互斥的,只有当线程1执行完毕释放n1锁对象之后,线程2才能够调用方法,或者反过来,如果没有加synchronized,那么这两个线程会同时运行。

输出:

21:43:58.261 [Thread-0] DEBUG com.nxz.Number - 1
21:43:58.263 [Thread-1] DEBUG com.nxz.Number - 2

可以看见,注意时间,可以明显的发现是先执行的线程1,线程1执行完毕之后才执行的线程2。

如果我修改一下代码,让他们获取的是不同锁对象:

public static void main(String[] args) throws InterruptedException {
    Number n1 = new Number();
    Number n2 = new Number();
    new Thread(()->{ n1.a(); }).start();//线程1获取锁对象为n1实例
    new Thread(()->{ n2.b(); }).start();//线程2获取锁对象为n2实例
}

由于是获取的不同锁对象,并且synchronized又是修饰在实例方法上面的,所以两个线程并不是互斥的,而是同时进行:

21:54:47.840 [Thread-0] DEBUG com.nxz.Number - 1
21:54:47.840 [Thread-1] DEBUG com.nxz.Number - 2

注意看时间,完全是同时进行的。

1.4.2 情况2

@Slf4j
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Number n1 = new Number();
        new Thread(()->{ n1.a(); }).start();//线程1获取锁对象为n1实例
        new Thread(()->{ n1.b(); }).start();//线程2获取锁对象为n1实例
    }

}
@Slf4j
class Number{
    public synchronized void a() {
        try {
            Thread.sleep(1000);//睡1s
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}

对于情况2,我执行方法b的线程睡眠1s;同理,两个synchronized都是修饰在了实例方法上,所以锁对象必须要为同一个实例对象,并且main方法中都是使用的同一个实例对象,所以他们的执行顺序应该是如下:

  • 如果线程1先获取到n1(对象锁),那么打印的顺序就为1,2;注意:中间会间隔几ms,因为两个线程并不是同时进行的,需要先等另一个线程释放锁对象另一个线程才会执行。
  • 如果线程2先获取到n1(对象锁),那么打印的顺序就为2,1;注意:中间会间隔1s左右,因为两个线程并不是同时进行的,需要先等另一个线程释放锁对象另一个线程才会执行。

1.4.3 情况3

@Slf4j
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Number n1 = new Number();
        new Thread(()->{ n1.a(); }).start();//线程1获取锁对象为n1实例
        new Thread(()->{ n1.b(); }).start();//线程2获取锁对象为n1实例
        new Thread(()->{ n1.c(); }).start();//线程3无需获取锁对象
    }

}

@Slf4j
class Number {
    public synchronized void a() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
    public void c(){
        log.debug("3");
    }
}

对于情况3,由于我的c方法没有使用synchronized方法修饰,所以当线程调用它的时候,无需获取锁对象,也无需等待其他资源释放锁对象,直接打印即可。

1.4.4 情况4

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        Number n1 = new Number();
        Number n2 = new Number();
        new Thread(()->{ n1.a(); }).start();//线程1获取锁对象为n1实例
        new Thread(()->{ n2.b(); }).start();//线程2获取锁对象为n2实例
    }

}

@Slf4j
class Number {
    public synchronized void a() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}

这种情况已经说明过了,具体可看情况1

1.4.5 情况5

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        Number n1 = new Number();
        new Thread(()->{ n1.a(); }).start();//线程1获取锁对象为Number.class
        new Thread(()->{ n1.b(); }).start();//线程2获取锁对象为n1实例
    }

}

@Slf4j
class Number {
    public static synchronized void a() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}

分析:

针对情况5,由于方法a的synchronized修饰到了静态方法上,所以方法a获取的锁对象为Number.class类,所以main中线程1获取到的锁对象是Number.class,而线程2获取到的锁对象为n1(实例对象)。由于获取的锁对象不同,所以两个线程并未互斥,各自执行即可。

22:23:19.853 [Thread-1] DEBUG com.nxz.Number - 2
22:23:20.869 [Thread-0] DEBUG com.nxz.Number - 1

可以稍微注意一下时间;由于两个线程无需等待释放锁对象,所以线程2未休眠直接输出即可,线程1休眠1s后再输出

1.4.6 情况6

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        Number n1 = new Number();
        new Thread(()->{ n1.a(); }).start();//线程1获取锁对象为Number.class
        new Thread(()->{ n1.b(); }).start();//线程2获取锁对象为Number.class
    }

}

@Slf4j
class Number {
    public static synchronized void a() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        log.debug("1");
    }
    public static synchronized void b() {
        log.debug("2");
    }
}

分析:

对于情况6,如果两个方法都使用了synchronized来修饰静态方法,那么线程1和线程2需要获取的锁对象都为Number.class;所以这里也会实现互斥,效果和情况2相同。

1.4.7 情况7

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        Number n1 = new Number();
        Number n2 = new Number();
        new Thread(()->{ n1.a(); }).start();//线程1获取锁对象为:Number.class
        new Thread(()->{ n2.b(); }).start();//线程2获取锁对象为:Number.class
    }

}

@Slf4j
class Number {
    public static synchronized void a() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        log.debug("1");
    }
    public static synchronized void b() {
        log.debug("2");
    }
}

分析:

针对情况7,这种情况,两个方法都使用了synchronized来修饰静态方法,那么线程1和线程2需要获取的锁对象都为Number.class;再main中,虽然new了两个Number实例,但是都是Number.class;所以对象锁依然相同,依然可以实现互斥,效果和情况6一致。

1.4.8 情况8

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        Number n1 = new Number();
        Number n2 = new Number();
        new Thread(()->{ n1.a(); }).start();//线程1获取锁对象为:Number.class
        new Thread(()->{ n2.b(); }).start();//线程2获取锁对象为:n2实例
    }

}

@Slf4j
class Number {
    public static synchronized void a() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}

分析:

针对情况8,这种情况一个synchronized修饰在实例方法上,一个修饰在静态方法上,需要获取的锁对象我也上面注释出写出来了,可以看见,二者无法形成互斥,各自执行即可。

1.5 变量的线程安全分析

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

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

成员变量的线程安全性:

成员变量指的是属于类的实例的变量,每个类的实例都有自己的一份成员变量副本。在多线程环境中,如果多个线程同时访问同一个类的实例的成员变量,可能会引发线程安全问题。成员变量的线程安全性取决于以下几点:

  1. 并发访问: 如果多个线程同时访问同一个类的实例的成员变量,并且至少一个线程进行了写操作,那么就可能存在线程安全问题。

  2. 同步机制: 可以使用互斥锁(mutex)等同步机制来保护成员变量的访问,确保在任意时刻只有一个线程能够对成员变量进行读写操作,从而避免竞态条件。

  3. 不变性(Immutability): 如果成员变量是不可变的(即一旦初始化后就不会再被修改),则可以避免许多线程安全问题,因为不需要担心并发写操作导致的数据不一致。

静态变量的线程安全性:

静态变量属于类本身,而不是类的实例。在多线程环境中,多个线程访问同一个类的静态变量可能引发线程安全问题。静态变量的线程安全性也需要特别关注:

  1. 共享性质: 多个线程共享同一个类的静态变量,因此可能存在并发访问的问题。

  2. 同步机制: 与成员变量类似,可以使用同步机制来保护静态变量的访问,确保线程安全。

  3. 可变性: 如果静态变量是可变的,即可被多个线程修改,那么需要特别小心,可能需要采用适当的同步措施。

需要注意的是,对于**不可变的成员变量和静态变量(final)**,它们本身是线程安全的,因为不会发生并发写操作。但是,当涉及到涉及多个变量之间的复合操作时,仍然需要考虑线程安全性。

成员变量的例子:

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        ThreadUnsafe test = new ThreadUnsafe();
        for (int i = 0; i < 2; i++) {//创建两个线程
            new Thread(() -> {
                test.method1(200);
            }, "Thread" + i).start();
        }
    }

}

@Slf4j
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);
    }
}

其中一种结果就是,如果线程2还没有执行add操作的时候,线程1就进行了remove,就会导致数组越界异常:

Exception in thread "Thread0" Exception in thread "Thread1" java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 10
    at java.base/java.util.ArrayList.fastRemove(ArrayList.java:642)
    at java.base/java.util.ArrayList.remove(ArrayList.java:508)
    at com.nxz.ThreadUnsafe.method3(Main.java:45)
    at com.nxz.ThreadUnsafe.method1(Main.java:35)
    at com.nxz.Main.lambda$main$0(Main.java:20)
    at java.base/java.lang.Thread.run(Thread.java:833)
java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 10
    at java.base/java.util.ArrayList.add(ArrayList.java:455)
    at java.base/java.util.ArrayList.add(ArrayList.java:467)
    at com.nxz.ThreadUnsafe.method2(Main.java:41)
    at com.nxz.ThreadUnsafe.method1(Main.java:34)
    at com.nxz.Main.lambda$main$0(Main.java:20)
    at java.base/java.lang.Thread.run(Thread.java:833)

分析:

  1. 共享资源访问: ThreadUnsafe 类中的 list 是一个共享资源,多个线程可能同时访问和修改它。method2method3 方法都在修改这个共享的 ArrayList 对象,可能导致数据不一致性和竞态条件。
  2. 竞态条件:method1 中,多个线程可能同时进入循环,并调用 method2method3。如果两个线程同时执行 list.add("1"),可能会导致元素被重复添加。同样,如果两个线程同时执行 list.remove(0),可能会导致索引越界或者数据丢失。
  3. 线程安全性: ArrayList 不是线程安全的数据结构,多个线程对其进行并发修改可能导致内部状态不一致,从而引发各种问题,包括数据损坏和程序崩溃。

这里主要是ArrayList的结果被破坏了:由于 ArrayList 不是线程安全的数据结构,多个线程对其并发修改可能导致内部状态不一致。例如,一个线程正在执行 list.add("1"),同时另外多个线程可能正在执行 list.remove(0),从而可能导致 ArrayList 的结构发生问题,进而导致 IndexOutOfBoundsException 或者不一致的数据。

但是注意,每个线程进来都得先执行method2(add),之后才能执行method3(remove);这里需要好好体会一下。

new Thread(() -> {
 list.add("1");        // 时间1. 会让内部 size ++
 list.remove(0); // 时间3. 再次 remove size-- 出现角标越界
}, "t1").start();

new Thread(() -> {
 list.add("2");        // 时间1(并发发生). 会让内部 size ++,但由于size的操作非原子性,  size 本该是2,但结果可能出现1
 list.remove(0); // 时间2. 第一次 remove 能成功, 这时 size 已经是0
}, "t2").start();

解决这个问题的方法之一是使用合适的同步机制来保护共享资源的访问。在这种情况下,我们可以使用锁来确保同一时刻只有一个线程能够访问 list。修改方法如下:

public synchronized void method2() {
    list.add("1");
}

public synchronized void method3() {
    if (!list.isEmpty()) {
        list.remove(0);
    }
}

但需要注意,使用锁会影响性能,因为每次只允许一个线程访问 method2method3。如果对性能有更高要求,可以考虑使用线程安全的数据结构,如 ConcurrentLinkedQueue

@Slf4j
class ThreadSafe {
    Queue queue = new ConcurrentLinkedQueue<>();

    public void method1(int loopNumber) {
        for (int i = 0; i < loopNumber; i++) {
            // { 临界区, 线程安全
            method2();
            method3();
            // } 临界区
        }
    }

    private void method2() {
        queue.add("1");
    }

    private void method3() {
        queue.poll(); // 使用 poll() 方法来安全地删除队首元素
    }
}

综上所述,原始代码存在竞态条件和线程安全问题,通过使用适当的同步机制或线程安全的数据结构,我们可以解决这些问题,并确保多线程环境下的正确性和稳定性。

静态变量的例子之前举过了。

1.5.2 局部变量是否线程安全?

局部变量通常是在方法内部或代码块内部声明的变量,它们的作用范围仅限于声明它们的方法或代码块。在多线程环境中,局部变量的线程安全性取决于以下几个因素:

  1. 作用范围: 局部变量的作用范围仅限于方法内部或代码块内部。因此,不同线程中的方法调用会创建各自的局部变量副本,从而避免了直接的线程间共享。每个线程都有自己的栈帧,用于存储局部变量,因此局部变量通常不会引发线程安全问题。

  2. 线程封闭性: 如果局部变量仅在单个线程内使用,即不会被其他线程访问或修改,那么它们是线程安全的。这是因为每个线程都有自己独立的方法调用栈,局部变量不会被其他线程访问到。

  3. 方法参数: 方法参数也是局部变量,它们在方法调用时会被传递给方法。如果方法参数是基本数据类型或者不可变对象,并且方法内部不会修改它们,那么它们通常是线程安全的。但是,如果方法参数是可变对象,并且方法内部修改了它们的状态,可能会引发线程安全问题。

需要注意的是,如果在多个线程之间共享了相同的可变对象作为方法参数,那么就可能存在线程安全问题。在这种情况下,需要确保对可变对象的访问是同步的,以避免并发修改。

总结:

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

举例分析:

public static void test1() {
    int i = 10;
    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
        LineNumberTable:
        line 10: 0
        line 11: 3
        line 12: 6
        LocalVariableTable:
        Start Length Slot Name Signature
            3 		4 	0 	i 	I

这段代码不会引发线程安全问题。原因是这段代码中的局部变量 i 是方法内部声明的,并且仅在方法内部使用,没有被多个线程共享。每个线程在调用 test1() 方法时都会创建自己的局部变量 i,并在方法内部对其进行自增操作。

由于每个线程都有自己独立的方法调用栈,局部变量在方法调用期间是线程私有的,不会被其他线程访问或影响。因此,该代码在多线程环境中是线程安全的。

对于之前的成员变量线程安全的那个例子,这里如果修改为局部变量:

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        ThreadSafe test = new ThreadSafe();
        for (int i = 0; i < 2; i++) {//创建两个线程
            new Thread(() -> {
                test.method1(200);
            }, "Thread" + i).start();
        }
    }

}

class ThreadSafe {
    public final void method1(int loopNumber) {
        //将list作为局部变量
        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);
    }
}

如果是上面这个代码,那么就不会有线程安全问题了。

分析:

在上述代码中,ThreadSafe 类的 method1 方法使用了将 ArrayList 作为局部变量的方式,因此每个线程在调用 method1 方法时都会创建自己的 ArrayList 对象,避免了多个线程之间对同一个 ArrayList 进行并发修改。这样的设计在代码范围内是线程安全的。

具体分析如下:

  1. method1 方法中的 ArrayList 对象 list 是局部变量,每个线程调用 method1 时都会创建自己的独立的 list 对象,互不影响。

  2. method2method3 方法接受 list 参数,而不是直接访问类的成员变量。这样的做法确保了每个线程都在自己的 list 对象上执行操作,不会影响其他线程的 list

  3. 在方法内部,method2 负责在 list 末尾添加元素,而 method3 负责删除 list 的第一个元素。由于每个线程都有自己独立的 list,因此 method2method3 的操作不会相互干扰。

综上所述,上述代码中不存在直接的线程安全问题。

注意:

如果局部变量的引用被暴露给了外部,那么就有可能会存在线程安全问题。

方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题?

仍然不会,原因上面已经分析过了,是同一个道理。

下面演示以下将局部变量的引用暴露给其他对象的例子:

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        ThreadSafe test = new ThreadSafeSubClass();//创建子类对象实例
        for (int i = 0; i < 2; i++) {//创建两个线程
            new Thread(() -> {
                test.method1(200);
            }, "Thread" + i).start();
        }
    }

}

class ThreadSafe {
    public void method1(int loopNumber) {
        //将list作为局部变量
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            method2(list);
            method3(list);//调用ThreadSafeSubClass类中的method3
        }
    }
    public void method2(ArrayList<String> list) {
        list.add("1");
    }
    public 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();
    }
}

输出:

Exception in thread "Thread-192" java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0
    at java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:64)
    at java.base/jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:70)
    at java.base/jdk.internal.util.Preconditions.checkIndex(Preconditions.java:266)
    at java.base/java.util.Objects.checkIndex(Objects.java:359)
    at java.base/java.util.ArrayList.remove(ArrayList.java:504)
    at com.nxz.ThreadSafeSubClass.lambda$method3$0(Main.java:46)
    at java.base/java.lang.Thread.run(Thread.java:833)
Exception in thread "Thread-254" java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0
    at java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:64)
    at java.base/jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:70)
    at java.base/jdk.internal.util.Preconditions.checkIndex(Preconditions.java:266)
    at java.base/java.util.Objects.checkIndex(Objects.java:359)
    at java.base/java.util.ArrayList.remove(ArrayList.java:504)
    at com.nxz.ThreadSafeSubClass.lambda$method3$0(Main.java:46)
    at java.base/java.lang.Thread.run(Thread.java:833)
Exception in thread "Thread-397" java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0
    at java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:64)
    at java.base/jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:70)
    at java.base/jdk.internal.util.Preconditions.checkIndex(Preconditions.java:266)
    at java.base/java.util.Objects.checkIndex(Objects.java:359)
    at java.base/java.util.ArrayList.remove(ArrayList.java:504)
    at com.nxz.ThreadSafeSubClass.lambda$method3$0(Main.java:46)
    at java.base/java.lang.Thread.run(Thread.java:833)

可见出错了,现在我们我们将ThreadSafe类中的method2、method3中的方法修饰符改为private,再将method1加一个final修饰,这样子类就无法重写我们的方法,就不会暴露引用出去了。

从这个例子可以看出 private final 提供【安全】的意义所在,请体会开闭原则中的【闭】。也从侧面说明了方法的访问修饰符也能再一定程度上防止线程安全问题发生。

1.6 常见线程安全类

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

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。

如下代码:

@Slf4j
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Hashtable<String, Object> table = new Hashtable<>();
        new Thread(()->{table.put("key","value1");}).start();
        new Thread(()->{table.put("key","value2");}).start();
    }
}

这里的hashTable是线程安全的类,他里面的put方法使用了synchronized关键字来修饰,表示这个方法的所有操作都是原子性的。

源码

但注意它们多个方法的组合不是原子的,见后面分析

1.6.1 线程安全类方法的组合

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

对于这个示例,get方法和put方法都是原则性(sychronized)的操作,但是把他们两个组合在一起,就有可能引发线程安全问题,分析如下:

sequenceDiagram
participant t1 as 线程1 
participant t2 as 线程2
participant table
t1 ->> table : get("key")==null
t2 ->> table : get("key")==null
t2 ->> table : put("key","value1")
t1 ->> table : put("key","value2")

按道理来说,我们只希望当需要的key不存在的时候,我们就放这个key和value,但是如果有多个线程进来,就会导致值被覆盖的情况。

1.6.2 不可变类线程安全性

String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的

有同学或许有疑问,String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安全的呢?

其实你查看源码就知道了,他并未在原有的字符串上进行改变,而是新new了一个对象出来:

1.6.3 实例分析

例1:

public class MyServlet extends HttpServlet {
    // 是否安全?非安全,map的安全类为HashTable
    Map<String,Object> map = new HashMap<>();
    // 是否安全? 安全,字符串属于不可变类
    String S1 = "...";
    // 是否安全? 安全,道理同上
    final String S2 = "...";
    // 是否安全? 非安全,线程安全类中并没有这个类
    Date D1 = new Date();
    // 是否安全? 非安全,虽然加上了final关键字,但是他里面的属性是可以由其他线程来修改的
    final Date D2 = new Date();
    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        // 使用上述变量
    }
}

例2:

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

因为userService是成员变量,并且该类中还有count成员变量,而且值会被修改,在这属于共享资源

如果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));
    }
}

这部分属于AOP方面的知识,我们知道,使用了@Componen注解之后,被该注解标记的类会被注册为单例对象,这就会导致多个线程来执行里面的before和after,从而出现线程安全问题。

一般解决的办法是写环绕通知,这样就不会导致线程安全问题了。

仅针对本例子(环绕通知),具体是否还有安全问题还得看业务逻辑。

例4:

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

以上代码是线程安全的,这是因为虽然MyServlet中有成员变量userService,然后UserService类中有成员变量userDao,属于共享资源,但是并没有一处地方是来修改userDao的值,所以这里并不会引发线程安全问题。

例5:

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

这是因为UserDaoImpl中有一个成员变量conn,并且在后续值会被多个线程修改,所以会引发线程安全问题。

例6:

public class MyServlet extends HttpServlet {
    // 是否安全
    private UserService userService = new UserServiceImpl();
    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        userService.update(...);
    }
}
public class UserServiceImpl implements UserService {
    public void update() {
        UserDao userDao = new UserDaoImpl();
        userDao.update();
    }
}
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();
    }
}

以上代码是线程安全的,这是因为MyServlet虽然有成员变量userService,属于共享资源,然后UserDaoImpl也有成员变量conn,并且值会被修改;但是你注意,UserDaoImpl对象的使用是在UserServiceImpl#update方法中;我们知道,每一个线程都有自己独立的栈空间,所以多个线程来操作conn变量实则是操作自己栈中的conn变量,互互不影响。

例7:

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

以上代码是不安全的,因为foo方法是一个抽象方法,对于里面的行为不确定,并且把SimpleDateFormat对象暴露给了外面,如果foo方法具体内容如下:

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

那么就会导致线程不安全。

1.7 习题

1.7.1 卖票练习

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

@Slf4j
public class Main {
    public static void main(String[] args) throws InterruptedException {

        TicketWindow ticketWindow = new TicketWindow(2000);

        //用来存储每一个线程
        List<Thread> list = new ArrayList<>();
        // 用来存储买出去多少张票
        //因为是在线程中统计的,这里使用线程安全的List集合类
        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;
    }

    /**
     * 卖票
     * @param amount 买入的数量
     * @return 返回买入的数量
     */
    public int sell(int amount) {
        if (this.count >= amount) {
            this.count -= amount;
            return amount;
        } else {
            return 0;
        }
    }
}

输出:

18:10:01.353 [main] DEBUG com.example.Main - selled count:2001
18:10:01.356 [main] DEBUG com.example.Main - remainder count:0

可以看见超卖了1张票,为什么会超卖呢?如果前面的章节你是认真看了,并且是考虑,这里其实并不难。

因为在TicketWindow类中,有一个成员变量count属于共享资源,如果只有读操作,那么不会存在线程安全,如果存在写操作,那么就会引发线程安全问题。

解决:只需将卖票的方法加上synchronized即可

public synchronized int sell(int amount) {
    if (this.count >= amount) {
        this.count -= amount;
        return amount;
    } else {
        return 0;
    }
}

这样,多个线程来进行卖票,由于锁对象:ticketWindow只有一个,每一个线程必须要等当前握有锁对象的线程执行完,其他线程才有机会来竞争锁对象。

这样问题就解决了。

但是,有的人可能会有疑问,关于这里的竟态条件的分析:

Thread t = new Thread(() -> {
    // 分析这里的竞态条件
    int count = ticketWindow.sell(randomAmount());
    sellCount.add(count);
});

之前不是说原子操作不会导致线程安全问题,但是原子操作的组合可能会导致,这里分析:

这里也是线程安全的,这里虽然是原子操作的组合,但是是两个不同的对象在操作,如int count = ticketWindow.sell(randomAmount());ticketWindow对象在操作,而sellCount.add(count);是一个list对象在操作,所以不会导致线程安全,与之前的HashTable那块有区别

1.7.2 转账练习

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

@Slf4j
public class Main {
    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给b转账
                a.transfer(b, randomAmount());
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                //b给a转账
                b.transfer(a, randomAmount());
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        // 查看转账2000次后的总金额
        //只要总金额仍然等于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);
        }
    }
}

输出:

08:25:21.005 [main] DEBUG com.example.Main - total:1170

可以看见,结果并不是2000,所以发生了线程安全问题。那这里怎么解决呢?

分析:

这里发生线程安全的地方是transfer方法,这个方法里面不仅对money变量进行了读,还进行了写操作;那么是否在这个方法上加个synchronized关键字就可以了呢?

答案是不可以的,因为这里面不仅对当前对象money是共享变量,而且,target.getMoney()也是一个共享变量,这里需要好好体会。这里账户有两个,两个的余额都需要保护,所以这里是不可以直接在方法上使用synchronized的。

所以这里的解决方法是让这两个共享变量都被保护:

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

输出

08:32:27.374 [main] DEBUG com.example.Main - total:2000

这下就正确了~

1.8 Monitor 概念

1.8.1 Java 对象头

以 32 位虚拟机为例

普通对象:

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

数组对象:

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

参考资料:https://stackoverflow.com/questions/26357186/what-is-in-java-object-header

1.8.2 Monitor(锁)

Monitor 被翻译为监视器管程

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

Monitor 结构如下:

  • 刚开始Monitor中Owner(所有者)为null
  • 当Thread-2执行synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只能有一个Owner
  • 在Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5也来执行synchronized(obj),就会进入EntryList BLOCKED
  • Thread-2执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争的时候是非公平的
  • 图中WaitSet中的Thread-0,Thread-1是之前获得过锁,但条件不满足进入WAITING状态的线程,后面讲wait-notify时会分析

注意:

synchronized必须是进入同一个对象的monitor才有上述的效果

不加synchronized的对象不会关联监视器,不遵从以上规则

1.8.3 原理之 synchronized

小故事

故事角色

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

小南要使用房间保证计算不被其它人干扰(原子性),最初,他用的是防盗锁,当上下文切换时,锁住门。这样,即使他离开了,别人也进不了门,他的工作就是安全的。

但是,很多情况下没人跟他来竞争房间的使用权。小女是要用房间,但使用的时间上是错开的,小南白天用,小女晚上用。每次上锁太麻烦了,有没有更简单的办法呢?

小南和小女商量了一下,约定不锁门了,而是谁用房间,谁把自己的书包挂在门口,但他们的书包样式都一样,因此每次进门前得翻翻书包,看课本是谁的,如果是自己的,那么就可以进门,这样省的上锁解锁了。万一书包不是自己的,那么就在门外等,并通知对方下次用锁门的方式。

后来,小女回老家了,很长一段时间都不会用这个房间。小南每次还是挂书包,翻书包,虽然比锁门省事了,但仍然觉得麻烦。

于是,小南干脆在门上刻上了自己的名字:【小南专属房间,其它人勿用】,下次来用房间时,只要名字还在,那么说明没人打扰,还是可以安全地使用房间。如果这期间有其它人要用这个房间,那么由使用者将小南刻的名字擦掉,升级为挂书包的方式。

同学们都放假回老家了,小南就膨胀了,在 20 个房间刻上了自己的名字,想进哪个进哪个。后来他自己放假回老家了,这时小女回来了(她也要用这些房间),结果就是得一个个地擦掉小南刻的名字,升级为挂书包的方式。老王觉得这成本有点高,提出了一种批量重刻名的方法,他让小女不用挂书包了,可以直接在门上刻上自己的名字。

后来,刻名的现象越来越频繁,老王受不了了:算了,这些房间都不能刻名了,只能挂书包。

1.8.4 原理之 synchronized 进阶

1. 轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。

轻量级锁对使用者是透明的,即语法仍然是 synchronized

假设有两个方法同步块,利用同一个对象加锁

static final Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步块 A
        method2();
    }
}
public static void method2() {
    synchronized( obj ) { //这里会发生锁重入
        // 同步块 B
    }
}
  • 创建锁记录(Lock Record)对象,每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word

  • 让锁记录中 Object reference 指向锁对象(Object),并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录

  • 如果 cas 替换成功,对象头中存储了 锁记录地址和状态 00 ,表示由该线程给对象加锁,这时图示如下:

  • 如果 cas 失败,有两种情况

    • 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
    • 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数

  • 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

  • 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头

    • 成功,则解锁成功
    • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
2. 锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

static final Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步块 A
        method2();
    }
}

以上代码其实就是前面的代码,只不过这时候又有其他的线程来调用method1,我这里才单独又写了一遍

  • 当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁

  • 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程,即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
    然后自己进入 Monitor 的 EntryList BLOCKED

  • 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

3. 自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

自旋重试成功的情况:

线程 1 (core 1 上) 对象 Mark 线程 2 (core 2 上)
- 10(重量锁) -
访问同步块,获取 monitor 10(重量锁)重量锁指针 -
成功(加锁) 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 访问同步块,获取 monitor
执行同步块 10(重量锁)重量锁指针 自旋重试
执行完毕 10(重量锁)重量锁指针 自旋重试
成功(解锁) 01(无锁) 自旋重试
- 10(重量锁)重量锁指针 成功(加锁)
- 10(重量锁)重量锁指针 执行同步块
-

自旋重试失败的情况:

线程 1 (core 1 上) 对象 Mark 线程 2 (core 2 上)
- 10(重量锁) -
访问同步块,获取 monitor 10(重量锁)重量锁指针 -
成功(加锁) 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 访问同步块,获取 monitor
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
- 10(重量锁)重量锁指针 阻塞
-
  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
  • Java 7 之后不能控制是否开启自旋功能
4. 偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。

1.9 wait notify

1.9.1 小故事 - 为什么需要 wait

  • 由于条件不满足,小南不能继续进行计算
  • 但小南如果一直占用着锁,其它人就得一直阻塞,效率太低

  • 于是老王单开了一间休息室(调用 wait 方法),让小南到休息室(WaitSet)等着去了,但这时锁释放开,其它人可以由老王随机安排进屋
    直到小M将烟送来,大叫一声 [ 你的烟到了 ] (调用 notify 方法)

  • 小南于是可以离开休息室,重新进入竞争锁的队列

1.9.2 API介绍

  • obj.wait() 让进入 object 监视器的线程到 waitSet 等待
  • obj.notify() 在 object 上正在 waitSet 等待的线程中挑一个唤醒
  • obj.notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒

它们都是线程之间进行协作的手段,都属于 Object 对象的方法。必须获得此对象的锁,才能调用这几个方法。例如:

@Slf4j
public class Main {

    final static Object obj = new Object();
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            synchronized (obj) { //必须持有obj的锁对象才能够使用xxx.wait
                log.debug("执行....");
                try {
                    obj.wait(); // 让线程在obj上一直等待下去
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.debug("其它代码....");
            }
        }).start();
        new Thread(() -> {
            synchronized (obj) {
                log.debug("执行....");
                try {
                    obj.wait(); // 让线程在obj上一直等待下去
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.debug("其它代码....");
            }
        }).start();
        // 主线程两秒后执行
        Thread.sleep(2);
        log.debug("唤醒 obj 上其它线程");
        synchronized (obj) {
            obj.notify(); // 唤醒obj上一个线程
        // obj.notifyAll(); // 唤醒obj上所有等待线程
        }
    }

}

例如如上代码:必须要先获得锁对象(synchronized (obj)),才可以调用wait(obj.wait())。

1.10 wait notify 的正确姿势

开始之前先看看

sleep(long n) wait(long n)区别:

  1. sleep 是 Thread 方法,而 wait 是 Object 的方法
  2. sleep 不需要强制和 synchronized 配合使用,但 wait 需要
  3. sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁
  4. 它们状态都是 TIMED_WAITING

1.10.1 step 1

思考下面的解决方案好不好,为什么?

@Slf4j
public class Main {

    static final Object room = new Object();
    static boolean hasCigarette = false;
    static boolean hasTakeout = false;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            synchronized (room) {
                log.debug("有烟没?[{}]", hasCigarette);
                if (!hasCigarette) {
                    log.debug("没烟,先歇会!");
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
                log.debug("有烟没?[{}]", hasCigarette);
                if (hasCigarette) {
                    log.debug("可以开始干活了");
                }
            }
        }, "小南").start();
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                synchronized (room) {
                    log.debug("可以开始干活了");
                }
            }, "其它人").start();
        }
        Thread.sleep(1000);
        new Thread(() -> {
            // 这里能不能加 synchronized (room)? 加上了其他人线程也是最后执行,而且会导致一直获取不到烟
            hasCigarette = true;
            log.debug("烟到了噢!");
        }, "送烟的").start();

    }
}

输出:

20:08:14.606 [小南] DEBUG com.nxz.Main - 有烟没?[false]
20:08:14.608 [小南] DEBUG com.nxz.Main - 没烟,先歇会!
20:08:15.606 [送烟的] DEBUG com.nxz.Main - 烟到了噢!
20:08:16.608 [小南] DEBUG com.nxz.Main - 有烟没?[true]
20:08:16.608 [小南] DEBUG com.nxz.Main - 可以开始干活了
20:08:16.608 [其它人] DEBUG com.nxz.Main - 可以开始干活了
20:08:16.608 [其它人] DEBUG com.nxz.Main - 可以开始干活了
20:08:16.608 [其它人] DEBUG com.nxz.Main - 可以开始干活了
20:08:16.608 [其它人] DEBUG com.nxz.Main - 可以开始干活了
20:08:16.609 [其它人] DEBUG com.nxz.Main - 可以开始干活了

为什么这里的for循环中的线程没有同步执行,而是等到小南线程执行完毕了之后才执行的?

因为在代码中,小南线程先获取到room锁对象,其他线程只有等到小南线程是否了这个锁对象之后才能开始执行。

问题:

  • 其它干活的线程,都要一直阻塞,效率太低
  • 小南线程必须睡足 2s 后才能醒来,就算烟提前送到,也无法立刻醒来
  • 加了 synchronized (room) 后,就好比小南在里面反锁了门睡觉,烟根本没法送进门,main 没加synchronized 就好像 main 线程是翻窗户进来的
  • 解决方法,使用 wait - notify 机制

1.10.2 step 2

思考下面的实现行吗,为什么?

@Slf4j
public class Main {

    static final Object room = new Object();
    static boolean hasCigarette = false;
    static boolean hasTakeout = false;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            synchronized (room) {
                log.debug("有烟没?[{}]", hasCigarette);
                if (!hasCigarette) {
                    log.debug("没烟,先歇会!");
                    try {
                        room.wait(2000);//注意,我这里改为了wait
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
                log.debug("有烟没?[{}]", hasCigarette);
                if (hasCigarette) {
                    log.debug("可以开始干活了");
                }
            }
        }, "小南").start();
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                synchronized (room) {
                    log.debug("可以开始干活了");
                }
            }, "其它人").start();
        }
        Thread.sleep(1000);
        new Thread(() -> {
            synchronized (room){
                hasCigarette = true;
                log.debug("烟到了噢!");
            }
        }, "送烟的").start();

    }

}

将小南线程中的sleep改为了wait之后,线程小南就会进入waitSet等待区等待并且不会占用cpu,此时其他人线程就会获取到room锁对象,然后开始干活,但是送烟线程就必须等到其他人线程执行结束之后才能够送烟。

输出:

20:19:31.097 [小南] DEBUG com.nxz.Main - 有烟没?[false]
20:19:31.098 [小南] DEBUG com.nxz.Main - 没烟,先歇会!
20:19:31.098 [其它人] DEBUG com.nxz.Main - 可以开始干活了
20:19:31.098 [其它人] DEBUG com.nxz.Main - 可以开始干活了
20:19:31.098 [其它人] DEBUG com.nxz.Main - 可以开始干活了
20:19:31.098 [其它人] DEBUG com.nxz.Main - 可以开始干活了
20:19:31.098 [其它人] DEBUG com.nxz.Main - 可以开始干活了
20:19:32.100 [送烟的] DEBUG com.nxz.Main - 烟到了噢!
20:19:33.098 [小南] DEBUG com.nxz.Main - 有烟没?[true]
20:19:33.098 [小南] DEBUG com.nxz.Main - 可以开始干活了
  • 解决了其它干活的线程阻塞的问题
  • 但如果有其它线程也在等待条件呢?

1.10.3 step 3

@Slf4j
public class Main {

    static final Object room = new Object();
    static boolean hasCigarette = false;
    static boolean hasTakeout = false;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            synchronized (room) {
                log.debug("有烟没?[{}]", hasCigarette);
                if (!hasCigarette) {
                    log.debug("没烟,先歇会!");
                    try {
                        room.wait(2000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
                log.debug("有烟没?[{}]", hasCigarette);
                if (hasCigarette) {
                    log.debug("可以开始干活了");
                }
            }
        }, "小南").start();

        new Thread(() -> {
            synchronized (room) {
                log.debug("外卖送到没?[{}]", hasTakeout);
                if (!hasTakeout) {
                    log.debug("没外卖,先歇会!");
                    try {
                        room.wait();//注意这里没有设置时间,默认为0,也就是一直等待
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                log.debug("外卖送到没?[{}]", hasTakeout);
                if (hasTakeout) {
                    log.debug("可以开始干活了");
                } else {
                    log.debug("没干成活...");
                }
            }
        }, "小女").start();

        Thread.sleep(1000);
        new Thread(() -> {
            synchronized (room){
                hasTakeout = true;
                log.debug("外卖到了噢!");
                room.notify();
            }
        }, "送外卖的").start();

    }

}

输出:

可见程序一直在等待

造成一直等待的原因有两个:

  1. 没有给小女线程设置等待时间
  2. 尽管使用了room.notify()来进行唤醒正在wait的线程,但是notify是在waitSet中挑一个唤醒,有可能唤醒不到想要唤醒的线程,我们把这种情况称为虚假唤醒

解决方法,改为 notifyAll

1.10.4 step 4

new Thread(() -> {
    synchronized (room){
        hasTakeout = true;
        log.debug("外卖到了噢!");
        room.notifyAll();
    }
}, "送外卖的").start();

输出:

20:39:46.073 [小南] DEBUG com.nxz.Main - 有烟没?[false]
20:39:46.074 [小南] DEBUG com.nxz.Main - 没烟,先歇会!
20:39:46.074 [小女] DEBUG com.nxz.Main - 外卖送到没?[false]
20:39:46.074 [小女] DEBUG com.nxz.Main - 没外卖,先歇会!
20:39:47.078 [送外卖的] DEBUG com.nxz.Main - 外卖到了噢!
20:39:47.078 [小南] DEBUG com.nxz.Main - 有烟没?[false]
20:39:47.079 [小女] DEBUG com.nxz.Main - 外卖送到没?[true]
20:39:47.079 [小女] DEBUG com.nxz.Main - 可以开始干活了

Process finished with exit code 0

可见程序正确退出。

问题:

  • 用 notifyAll 仅解决某个线程的唤醒问题,但使用 if + wait 判断仅有一次机会,一旦条件不成立,就没有重新判断的机会了
  • 解决方法,用 while + wait,当条件不成立,再次 wait

1.10.5 step 5

将 if 改为 while

if (!hasCigarette) {
    log.debug("没烟,先歇会!");
    try {
        room.wait(2000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}

改为:

while (!hasCigarette) {
    log.debug("没烟,先歇会!");
    try {
        room.wait(2000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
}

即可解决上面的问题。

1.11 同步模式之保护性暂停

1.11.1 定义

Guarded Suspension,用在一个线程等待另一个线程的执行结果

要点

  • 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
  • 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
  • JDK 中,join 的实现、Future 的实现,采用的就是此模式
  • 因为要等待另一方的结果,因此归类到同步模式

1.11.2 实现

class GuardedObject {
    private Object response;
    private final Object lock = new Object();
    public Object get() {
        synchronized (lock) {
            // 条件不满足则等待
            while (response == null) {
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            return response;
        }
    }
    public void complete(Object response) {
        synchronized (lock) {
            // 条件满足,通知等待线程
            this.response = response;
            lock.notifyAll();
        }
    }
}

1.11.3 应用

一个线程等待另一个线程的执行结果

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        GuardedObject guardedObject = new GuardedObject();
        new Thread(() -> {
            try {
                // 子线程执行下载
                List<String> response = download();
                log.debug("download complete...");
                guardedObject.complete(response);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }).start();
        log.debug("waiting...");
        // 主线程阻塞等待
        Object response = guardedObject.get();
        log.debug("get response: [{}] lines", ((List<String>) response).size());
    }
    //下载百度的html页面(无需过度关心)
    public static List<String> download() throws IOException {
        HttpURLConnection conn = (HttpURLConnection)new URL("https://www.baidu.com/").openConnection();
        ArrayList<String> lines = new ArrayList<>();
        BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
        String line;
        while ((line= reader.readLine())!= null){
            lines.add(line);
        }
        return lines;
    }

}

class GuardedObject {
    private Object response;
    private final Object lock = new Object();
    public Object get() {
        synchronized (lock) {
            // 条件不满足则等待
            while (response == null) {
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            return response;
        }
    }
    public void complete(Object response) {
        synchronized (lock) {
            // 条件满足,通知等待线程
            this.response = response;
            lock.notifyAll();
        }
    }
}

输出

20:32:00.714 [main] DEBUG com.nxz.Main - waiting...
20:32:01.108 [Thread-0] DEBUG com.nxz.Main - download complete...
20:32:01.109 [main] DEBUG com.nxz.Main - get response: [3] lines

1.11.4 带超时版GuardedObject

如果要控制超时时间呢

@Slf4j
class GuardedObjectV2 {
    private Object response;
    private final Object lock = new Object();
    public Object get(long millis) {
        synchronized (lock) {
            // 1) 记录最初时间
            long begin = System.currentTimeMillis();
            // 2) 已经经历的时间
            long timePassed = 0;
            while (response == null) {
                // 4) 假设 millis 是 1000,结果在 400 时唤醒了,那么还有 600 要等
                long waitTime = millis - timePassed;
                log.debug("waitTime: {}", waitTime);
                if (waitTime <= 0) {
                    log.debug("break...");
                    break;
                }
                try {
                    lock.wait(waitTime);//这里时间的填写时关键!!!
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 3) 如果提前被唤醒,这时已经经历的时间假设为 400
                timePassed = System.currentTimeMillis() - begin;
                log.debug("timePassed: {}, object is null {}", timePassed, response == null);
            }
            return response;
        }
    }
    public void complete(Object response) {
        synchronized (lock) {
            // 条件满足,通知等待线程
            this.response = response;
            log.debug("notify...");
            lock.notifyAll();
        }
    }
}

1.12 异步模式之生产者/消费者

1.12.1 定义

要点

  • 与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
  • 消费队列可以用来平衡生产和消费的线程资源
  • 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
  • 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
  • JDK 中各种阻塞队列,采用的就是这种模式

1.12.2 实现

/**
 * 定义一个消息队列
 */
@Slf4j
class MessageQueue {
    private LinkedList<Message> queue;
    //队列的容量
    private int capacity;
    public MessageQueue(int capacity) {
        this.capacity = capacity;
        queue = new LinkedList<>();
    }
    //消费消息
    public Message take() {
        synchronized (queue) {
            while (queue.isEmpty()) {
                log.debug("没货了, wait");
                try {
                    queue.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            Message message = queue.removeFirst();
            //这里只要消息被消费了,表示队列可以容纳新的消息了,通知生产消息的线程
            queue.notifyAll();
            return message;
        }
    }
    //生产消息
    public void put(Message message) {
        synchronized (queue) {
            while (queue.size() == capacity) {
                log.debug("库存已达上限, wait");
                try {
                    queue.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            queue.addLast(message);
            //这里只要消息被生产了,表示队列可以有新的消息被消费,通知消费消息的线程
            queue.notifyAll();
        }
    }
}

1.12.3 应用

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        MessageQueue messageQueue = new MessageQueue(2);
        // 4 个生产者线程, 下载任务
        for (int i = 0; i < 4; i++) {
            //这里在lambda表达是式中,变量必须要用final修饰,而i是一直在变化的,所以这里用id来接收一下
            int id = i;
            new Thread(() -> {
                    messageQueue.put(new Message(id,"值:"+id));
            }, "生产者" + i).start();
        }
        // 1 个消费者线程, 处理结果
        new Thread(() -> {
            while (true) {
                Message message = messageQueue.take();
            }
        }, "消费者").start();
    }

}

/**
 * 定义一个消息类
 */
@Data
@AllArgsConstructor
class Message {
    private int id;
    private Object message;

}

/**
 * 定义一个消息队列
 */
@Slf4j
class MessageQueue {
    private LinkedList<Message> queue;
    //队列的容量
    private int capacity;

    public MessageQueue(int capacity) {
        this.capacity = capacity;
        queue = new LinkedList<>();
    }

    //消费消息
    public Message take() {
        synchronized (queue) {//需要使用wait,所以要配合synchronized
            while (queue.isEmpty()) {
                log.debug("没货了, wait");
                try {
                    queue.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            Message message = queue.removeFirst();
            log.debug("消费了消息:"+message.toString());
            //这里只要消息被消费了,表示队列可以容纳新的消息了,通知生产消息的线程
            queue.notifyAll();
            return message;
        }
    }

    //生产消息
    public void put(Message message) {
        synchronized (queue) {//需要使用wait,所以要配合synchronized
            while (queue.size() == capacity) {
                log.debug("库存已达上限, wait");
                try {
                    queue.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            queue.addLast(message);
            //这里只要消息被生产了,表示队列可以有新的消息被消费,通知消费消息的线程
            queue.notifyAll();
        }
    }
}

输出:

结果

可以看见有4个生产者在生产消息,但是队列的容量只有2,当消息满了之后就必须等待消费者消费了消息才能继续生产,同时消费者消费完队列中的消息之后,也必须要等待生产者生产出消息才能够继续消费。

1.13 Park & Unpark

在Java中,parkunpark是用于线程同步的工具,通常与java.util.concurrent.locks.LockSupport类一起使用。它们提供了一种基于许可(permit)的线程阻塞和解除阻塞的机制,用于实现更高级别的同步和通信。

  1. park方法:
    park方法是用来阻塞当前线程的执行。当一个线程调用park方法时,它会被阻塞,直到满足某个条件才会继续执行。park方法有多个重载形式,但最常用的形式是:
public static void park();

此方法会使当前线程进入阻塞状态,直到被其他线程调用unpark方法来解除阻塞,或者线程被中断。

  1. unpark方法:
    unpark方法用于解除由于调用park方法而阻塞的线程。每个线程都有一个与之关联的许可(permit),unpark方法可以提供一个许可给目标线程,使其可以继续执行。unpark方法有多个重载形式,但最常用的形式是:
public static void unpark(Thread thread);

unpark方法可以在park方法调用之前被调用,也可以在park方法调用之后被调用。如果线程在调用park方法之前已经调用了unpark方法,那么在调用park方法时,线程不会被阻塞,而是会直接继续执行。

使用parkunpark可以实现一些复杂的线程同步和通信机制。例如,可以通过parkunpark来实现一个自定义的线程间通信机制,或者用于构建各种同步工具,如信号量、倒计时器等。

1.13.1 基本使用

它们是 LockSupport 类中的方法

// 暂停当前线程
LockSupport.park();

// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)

先 park 再 unpark

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            log.debug("start...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.debug("park...");
            LockSupport.park();
            log.debug("resume...");
        },"t1");
        t1.start();
        Thread.sleep(2000);
        log.debug("unpark...");
        LockSupport.unpark(t1);
    }

}

输出:

21:56:40.980 [t1] DEBUG com.nxz.Main - start...
21:56:41.993 [t1] DEBUG com.nxz.Main - park...
21:56:42.984 [main] DEBUG com.nxz.Main - unpark...
21:56:42.984 [t1] DEBUG com.nxz.Main - resume...

开始时,主线程和t1线程同时执行,主线程需要睡2s,t1线程睡1s,然后t1执行park(),进入阻塞状态,当主线程睡醒了之后,执行unpark,t1线程得以继续执行。

先 unpark 再 park

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            log.debug("start...");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.debug("park...");
            LockSupport.park();
            log.debug("resume...");
        },"t1");
        t1.start();
        Thread.sleep(1000);
        log.debug("unpark...");
        LockSupport.unpark(t1);
    }

}

输出:

23:01:14.497 [t1] DEBUG com.nxz.Main - start...
23:01:15.500 [main] DEBUG com.nxz.Main - unpark...
23:01:16.510 [t1] DEBUG com.nxz.Main - park...
23:01:16.510 [t1] DEBUG com.nxz.Main - resume...

可以看见当先执行unpark之后,再次进入的park的时候并不会阻塞。

4.13.2 特点

与 Object 的 wait & notify 相比

  • wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必
  • park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么【精确】
  • park & unpark 可以先 unpark,而 wait & notify 不能先 notify

1.13.3 原理

parkunpark的实现原理涉及到操作系统调度、内部数据结构和线程同步机制。下面我会更详细地解释它们的实现原理。

park原理

  1. 当一个线程调用park方法时,首先会尝试获取自己的许可(permit)。如果许可是可用的(之前调用过unpark),那么当前线程会立即获取到许可,并park方法会返回。此时,许可会变为不可用状态。

  2. 如果线程的许可不可用,park方法会将当前线程标记为阻塞状态,并将线程放入一个特定的等待队列中,以等待许可的释放。

  3. 当线程被标记为阻塞状态后,操作系统会暂停线程的执行,将其放置在等待队列上,然后允许其他可运行线程继续执行。

  4. 当其他线程调用目标线程的unpark方法时,会将目标线程的许可设置为可用状态。如果目标线程此时正处于阻塞状态,它会被唤醒,并从阻塞状态恢复到可运行状态。park方法会返回,许可变为不可用状态。

  5. 如果目标线程已经调用了unpark方法,但在调用park方法之前,那么调用park方法时,它会直接获取许可,而不会被阻塞。

unpark原理

  1. 当一个线程调用unpark方法时,会将目标线程的许可设置为可用状态。

  2. 如果目标线程此时正处于阻塞状态,它会从等待队列中被唤醒,恢复到可运行状态。

  3. 如果目标线程尚未调用park方法或已经调用了park方法但在此之前调用了unpark方法,那么在目标线程调用park方法时,它会直接获取许可,而不会被阻塞。

总结起来,parkunpark的实现依赖于许可的状态以及操作系统的线程调度机制。当线程调用park方法时,它会被放入等待队列并阻塞,直到其他线程调用了unpark方法。这种机制可以用于实现各种线程同步和通信的模式,但由于它相对底层,使用时需要小心,避免出现死锁、饥饿等问题。在实际应用中,更高级别的同步工具如锁、信号量、倒计时器等可能更容易使用和维护。

1.14 多把锁

一间大屋子有两个功能:睡觉、学习,互不相干。

现在小南要学习,小女要睡觉,但如果只用一间屋子(一个对象锁)的话,那么并发度很低

解决方法是准备多个房间(多个对象锁)

例如:

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        BigRoom bigRoom = new BigRoom();
        new Thread(() -> {
            try {
                bigRoom.study();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        },"小南").start();
        new Thread(() -> {
            try {
                bigRoom.sleep();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        },"小女").start();
    }

}

@Slf4j
class BigRoom {
    public void sleep() throws InterruptedException {
        synchronized (this) {
            log.debug("开始sleep....");
            Thread.sleep(2000);
            log.debug("sleeping 2 小时");
        }
    }
    public void study() throws InterruptedException {
        synchronized (this) {
            log.debug("开始study....");
            Thread.sleep(1000);
            log.debug("study 1 小时");
        }
    }
}

输出:

20:44:15.710 [小南] DEBUG com.nxz.BigRoom - 开始study....
20:44:16.712 [小南] DEBUG com.nxz.BigRoom - study 1 小时
20:44:16.712 [小女] DEBUG com.nxz.BigRoom - 开始sleep....
20:44:18.721 [小女] DEBUG com.nxz.BigRoom - sleeping 2 小时

Process finished with exit code 0

可以看见,当执行的时候,两个线程会同时启动,但是由于锁只有一把,所以当开始study的时候,并不会来时sleep,而是必须要等study完了之后才可以进行sleep。

因此上面的代码效率比较低,对于两种完全不相干的行为,我们可以使用多把锁来优化执行效率。

改进:

@Slf4j
class BigRoom {
    /**
     * 创建两个锁对象
     */
    public static final Object studyRoom = new Object();
    public static final Object sleepRoom = new Object();
    public void sleep() throws InterruptedException {
        synchronized (sleepRoom) {//获取sleep锁
            log.debug("开始sleep....");
            Thread.sleep(2000);
            log.debug("sleeping 2 小时");
        }
    }
    public void study() throws InterruptedException {
        synchronized (studyRoom) {//获取study锁
            log.debug("开始study....");
            Thread.sleep(1000);
            log.debug("study 1 小时");
        }
    }
}

输出:

20:49:11.034 [小南] DEBUG com.nxz.BigRoom - 开始study....
20:49:11.034 [小女] DEBUG com.nxz.BigRoom - 开始sleep....
20:49:12.043 [小南] DEBUG com.nxz.BigRoom - study 1 小时
20:49:13.041 [小女] DEBUG com.nxz.BigRoom - sleeping 2 小时

Process finished with exit code 0

可以看见,此时小南和小女两个线程时同时进行的,大大提高了效率。

1.15 活跃性

1.15.1 死锁

有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁

t1 线程 获得 A对象 锁,接下来想获取 B对象 的锁

t2 线程 获得 B对象 锁,接下来想获取 A对象 的锁

即是两个线程互相获取对方已经获取到的锁就会发生死锁。

经典的案例:

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        Object A = new Object();
        Object B = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (A) {
                log.debug("lock A");
                try {
                    Thread.sleep(1000);//等待一会,保证t2线程要先获取B锁
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (B) {
                    log.debug("lock B");
                    log.debug("操作...");
                }
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            synchronized (B) {
                log.debug("lock B");
                try {
                    Thread.sleep(500);//等待一会,保证t1线程要先获取A锁
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (A) {
                    log.debug("lock A");
                    log.debug("操作...");
                }
            }
        }, "t2");
        t1.start();
        t2.start();
    }

}

输出:

20:52:49.001 [t2] DEBUG com.nxz.Main - lock B
20:52:49.001 [t1] DEBUG com.nxz.Main - lock A

可以发现两个线程同时启动,当线程t1获取到锁对象A之后,睡眠1s然后再去获取锁对象B,但是由于此时锁对象B已经被t2线程获取了,所以只能等待,同时t2也是同样的道理。

1.15.2 哲学家就餐问题

5个哲学家和5根筷子

有五位哲学家,围坐在圆桌旁。

  • 他们只做两件事,思考和吃饭,思考一会吃口饭,吃完饭后接着思考。
  • 吃饭时要用两根筷子吃,桌上共有 5 根筷子,每位哲学家左右手边各有一根筷子。
  • 如果筷子被身边的人拿着,自己就得等待

代码实现:

@Slf4j
public class Main {

    public static void main(String[] args) throws InterruptedException {
        //就餐
        Chopstick c1 = new Chopstick("1");
        Chopstick c2 = new Chopstick("2");
        Chopstick c3 = new Chopstick("3");
        Chopstick c4 = new Chopstick("4");
        Chopstick c5 = new Chopstick("5");
        new Philosopher("苏格拉底", c1, c2).start();
        new Philosopher("柏拉图", c2, c3).start();
        new Philosopher("亚里士多德", c3, c4).start();
        new Philosopher("赫拉克利特", c4, c5).start();
        new Philosopher("阿基米德", c5, c1).start();
    }

}

/**
 * 筷子类
 */
@Slf4j
class Chopstick {
    String name;
    public Chopstick(String name) {
        this.name = name;
    }
    @Override
    public String toString() {
        return "筷子{" + name + '}';
    }
}

/**
 * 哲学家类
 */
@Slf4j
class Philosopher extends Thread {
    Chopstick left;
    Chopstick right;
    public Philosopher(String name, Chopstick left, Chopstick right) {
        super(name);
        this.left = left;
        this.right = right;
    }
    private void eat() throws InterruptedException {
        log.debug("eating...");
        Thread.sleep(1000);
    }
    @Override
    public void run() {
        while (true) {
            // 获得左手筷子
            synchronized (left) {
                // 获得右手筷子
                synchronized (right) {
                    // 吃饭
                    try {
                        eat();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
                // 放下右手筷子
            }
            // 放下左手筷子
        }
    }
}

输出:

执行结构

可以发现执行了一段时间之后,就发生了死锁等待。

1.15.3 活锁

活锁(Livelock)是一种类似于死锁的并发问题,活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束。与死锁不同,活锁中的线程是活跃的,它们不会被阻塞,但由于彼此之间的互相影响,它们无法取得进展。活锁情况下,线程们表现出来好像在不断地做工作,但实际上却没有真正地取得任何进展。

在Java中,活锁通常发生在以下情况下:

  1. 相互礼让:两个或多个线程试图避免相互竞争,导致它们在每次尝试时都让步给其他线程,从而永远无法完成自己的任务。

  2. 无法达成一致:线程根据其他线程的行为来调整自己的行为,但其他线程也在调整,从而导致它们在不断地改变自己的决策,最终无法达成共识。

避免和解决活锁的方法类似于解决死锁的方法:

  1. 随机等待:引入随机性可以减少线程在竞争条件下的相互影响。例如,线程可以引入随机的等待时间,以降低冲突发生的概率。

  2. 使用超时机制:在等待资源时,可以使用超时机制,如果在一定时间内没有获得资源,线程可以采取不同的策略,例如回退或重试。

  3. 协调行为:线程之间的协作和协调可以帮助避免活锁。使用互斥量、条件变量等同步工具可以确保线程按照期望的顺序执行。

  4. 调整算法:修改算法或策略,使得线程在冲突时更有可能取得进展,从而减少相互影响的可能性。

  5. 日志和监控:在应用中引入日志和监控机制,以便在出现活锁时能够及时检测和诊断问题。

需要注意的是,活锁是一种比较难以检测和解决的问题,因为线程仍然在运行,没有明显的阻塞现象,所以很难直观地识别。在编写多线程代码时,需要仔细考虑线程之间的协作和资源竞争,以尽量避免出现活锁情况。

举例:

@Slf4j
public class Main {
    static volatile int count = 10;
    static final Object lock = new Object();
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            // 期望减到 0 退出循环
            while (count > 0) {
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                count--;
                log.debug("count: {}", count);
            }
        }, "t1").start();
        new Thread(() -> {
            // 期望超过 20 退出循环
            while (count < 20) {
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                count++;
                log.debug("count: {}", count);
            }
        }, "t2").start();
    }
}

如果你执行,会发现二者都在互相改变count的值,导致谁也结束不了。

1.14.4 饥饿

很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束,饥饿的情况不
易演示,讲读写锁时会涉及饥饿问题。

在Java线程中,饥饿(Starvation)是一种并发问题,指的是某个线程由于竞争资源被其他线程占用,导致它无法获得足够的CPU时间或资源来执行自己的任务,从而一直无法取得进展。饥饿可能会导致某些线程长时间处于阻塞或等待状态,而无法得到执行的机会,从而影响整体的系统性能。

常见的饥饿场景包括:

  1. 优先级反转:在多线程环境中,线程的优先级可以影响它们获得CPU时间的顺序。如果一个低优先级的线程持续竞争一个共享资源,而高优先级的线程却无法获得执行机会,那么高优先级的线程就可能陷入饥饿状态。

  2. 公平性问题:某些线程调度算法可能存在不公平的情况,导致某些线程一直无法获得执行的机会,而其他线程占据了大部分资源。

  3. 资源独占:如果某个线程一直持有某个重要资源,并且其他线程需要该资源才能继续执行,那么这些等待资源的线程可能会陷入饥饿状态。

如何解决线程饥饿问题:

  1. 使用公平锁:在资源竞争较为激烈的情况下,可以使用公平锁来确保线程按照请求的顺序获得资源,从而减少不公平现象。

  2. 调整线程优先级:虽然线程优先级不能完全解决饥饿问题,但在一些情况下,适当提高某些线程的优先级可能有助于它们更频繁地获得执行机会。

  3. 避免长时间占用资源:尽量避免一个线程长时间占用某个资源,可以使用合适的同步机制来确保资源能够在合理的时间内被释放。

  4. 使用线程池:线程池可以有效地管理线程的分配和执行,避免线程过多竞争资源的情况,从而减少饥饿的可能性。

  5. 合理的任务划分:将任务划分为适当的粒度,避免某些任务耗时过长,导致其他任务无法得到执行。

总之,解决线程饥饿问题需要综合考虑线程的优先级、资源管理、同步机制等因素,以保障所有线程都能获得公平的执行机会,避免个别线程长时间被阻塞。

1.16 ReentrantLock

在Java中,ReentrantLock 是一种可重入的互斥锁,用于管理线程对临界区资源的访问。它提供了与 synchronized 关键字相似的功能,但也具有更多的灵活性和控制权。ReentrantLock 允许线程重复地获取相同的锁,而不会导致死锁。下面详细解释一下 ReentrantLock 的特性和用法:

1.16.1 可重入性

ReentrantLock 是可重入锁,这意味着一个线程可以多次获得同一个锁而不会被阻塞。每次获得锁后,锁的持有计数会增加,线程必须在释放锁的次数等于获得锁的次数后才能完全释放锁。如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住。

public static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
    lock.lock();
    try {
        log.debug("获取到锁");
        m1();
    } finally {
        log.debug("释放锁");
        lock.unlock();
    }
}

public static void m1() {
    log.debug("进入m1 方法中");
    lock.lock();
    try {
        log.debug("方法m1获取到锁——重入");
        m2();
    } finally {
        log.debug("释放锁");
        lock.unlock();
    }
}

public static void m2() {
    log.debug("进入m2 方法中");
    lock.lock();
    try {
        log.debug("方法m2获取到锁——重入");
    } finally {
        log.debug("释放锁");
        lock.unlock();
    }
}

输出:

20:41:11.775 [main] DEBUG com.example.javatest.TestReentratLock - 获取到锁
20:41:11.776 [main] DEBUG com.example.javatest.TestReentratLock - 进入m1 方法中
20:41:11.776 [main] DEBUG com.example.javatest.TestReentratLock - 方法m1获取到锁——重入
20:41:11.776 [main] DEBUG com.example.javatest.TestReentratLock - 进入m2 方法中
20:41:11.776 [main] DEBUG com.example.javatest.TestReentratLock - 方法m2获取到锁——重入
20:41:11.776 [main] DEBUG com.example.javatest.TestReentratLock - 释放锁
20:41:11.776 [main] DEBUG com.example.javatest.TestReentratLock - 释放锁
20:41:11.776 [main] DEBUG com.example.javatest.TestReentratLock - 释放锁

可以发现在main中,获取到锁之后,进入方法m1,然后方法m1中又调用了lock.lock();这里就是锁重入了

1.16.2 获取锁

可以使用 ReentrantLocklock() 方法来获取锁。如果锁当前没有被其他线程持有,则获得锁并继续执行。如果锁已经被其他线程持有,则当前线程会被阻塞,直到锁被释放。

ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 临界区代码
} finally {
    lock.unlock(); // 必须在 finally 中释放锁,以确保异常时锁的释放
}

1.16.3 解锁

使用 ReentrantLockunlock() 方法来释放锁。通常应该在 finally 块中释放锁,以确保在任何情况下都能正确释放锁,避免死锁。

再次强调,必须要在finally中释放锁。也就是如果你在之前上锁了,就必须在finally中解锁,也就是你使用ReentantLock你就必须使用try-finally块

1.16.4 条件变量

synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待

ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比synchronized 是那些不满足条件的线程都在一间休息室等消息,而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒。

ReentrantLock 的条件变量是通过 newCondition() 方法创建的,它是 java.util.concurrent.locks.Condition 接口的实例。条件变量允许线程在某个特定条件满足时等待,以及在其他线程满足该条件时通知等待线程继续执行。

条件变量通常与 ReentrantLock 结合使用,以在特定条件下对线程进行同步和协调。它们提供了更灵活的等待和通知机制,相较于传统的 wait()notify() 方法,条件变量更安全且功能更强大。

以下是如何使用条件变量的基本步骤:

  1. 创建 ReentrantLock 实例和条件变量:
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
  1. 在一个线程中等待条件:
lock.lock();
try {
    while (!conditionIsMet()) {
        condition.await(); // 等待条件满足,释放锁
    }
    // 执行操作
} finally {
    lock.unlock();
}
  1. 在另一个线程中通知条件满足:
lock.lock();
try {
    condition.signal(); // 或者 signalAll() 通知等待的线程
} finally {
    lock.unlock();
}

在这个过程中,await() 方法会释放当前持有的锁,并使当前线程进入等待状态。当其他线程调用 signal()(或 signalAll())方法来通知条件变量时,等待的线程将被唤醒,并再次尝试获取锁以继续执行。由于使用了 ReentrantLock,这些操作都可以保证线程安全性。

signal类似于object中的notify,唤醒某个waitSet中的某一个线程,而signalAll类似于object中的notifyAll,唤醒某个waitSet中的所有线程

需要注意以下几点:

  • 在调用 await() 之前,需要先获得锁,以确保等待的线程与通知线程处于同一个锁的保护下。
  • 使用 while 循环来检查条件是否满足,而不是使用 if。这是因为在多线程环境下,线程可能因为虚假唤醒(spurious wakeup)而在不满足条件的情况下被唤醒。
  • 通知等待线程时,可以使用 signal() 方法通知一个等待线程,或者使用 signalAll() 方法通知所有等待线程。
  • 条件变量可以有多个,每个条件变量管理不同的等待集合和通知机制。这使得在复杂的同步场景中可以更精细地控制线程的等待和唤醒。

总之,ReentrantLock 的条件变量提供了一种灵活且安全的线程等待和通知机制,适用于需要更细粒度控制的并发编程场景。

这里的条件变量就好比等待的时候需要进入waitSet进行等待,只不过这里根据等待条件的不同,可以进入不同的waitSet等待

1.16.5 公平性

ReentrantLock 支持公平性和非公平性。在公平锁模式下,锁会按照线程请求的顺序分配,保证等待时间较长的线程会更早地获得锁。非公平锁模式下,不保证锁的获取顺序,可能会导致某些线程始终无法获取到锁。这个就有效防止了饥饿的发生

在创建 ReentrantLock 实例时,可以传入一个布尔值来设置锁的公平性,默认为非公平锁

ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
ReentrantLock unfairLock = new ReentrantLock(false); // 非公平锁

一般不会设置为公平锁,因为会降低并发性

1.16.6 可中断性

lockInterruptibly()ReentrantLock 类提供的一个方法,用于尝试获取锁,但在等待锁的过程中可以响应中断。如果当前线程在等待锁的过程中被中断,它会抛出 InterruptedException 并且中断状态会被清除

tryLock() 方法不同,lockInterruptibly() 方法在等待锁的过程中,如果当前线程被其他线程中断,那么它会响应这个中断并立即抛出异常,而不是一直等待直到获取到锁或超时。

这个方法适用于需要在等待锁的同时能够响应中断的情况,从而能够更加灵活地控制线程的行为。

这里是一个使用 lockInterruptibly() 方法的示例:

ReentrantLock lock = new ReentrantLock();
try {
    lock.lockInterruptibly(); // 尝试获取锁,可以响应中断
    try {
        // 获取到锁,执行操作
    } finally {
        lock.unlock();
    }
} catch (InterruptedException e) {
    // 当前线程被中断,可以进行相应的处理
}

在使用 lockInterruptibly() 方法时需要注意以下几点:

  • 当线程在等待锁的过程中被中断时,会立即抛出 InterruptedException。因此,在捕获 InterruptedException 后,需要根据业务逻辑决定如何处理中断,可能是继续执行某些清理操作,或者重新抛出异常,或者恢复中断状态等。
  • tryLock() 类似,当成功获取锁并在临界区操作完成后,务必在 finally 块中释放锁,以避免资源泄漏。
  • lockInterruptibly() 方法可以保证在等待锁的过程中能够响应中断,但并不会影响中断状态。也就是说,如果在等待锁之前线程已经处于中断状态,那么在抛出 InterruptedException 后,线程的中断状态会被清除。如果需要保留中断状态,需要在捕获异常后再次设置中断状态。

总之,lockInterruptibly() 是一个适用于需要在线程等待锁的过程中能够响应中断的情况下使用的方法,能够在一些并发场景中提供更灵活的控制。

例子:

@Slf4j
public class TestReentratLock {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();

        Thread t1 = new Thread(() -> {
            log.debug("启动...");
            try {
                lock.lockInterruptibly();
            } catch (InterruptedException e) {
                e.printStackTrace();
                log.debug("等锁的过程中被打断");
                return;
            }
            try {
                log.debug("获得了锁");
            } finally {
                lock.unlock();
            }
        }, "t1");

        lock.lock();
        log.debug("获得了锁");
        t1.start();
        try {
            Thread.sleep(1000);
            //t1.interrupt();
            log.debug("执行打断");
        } finally {
            lock.unlock();
        }
    }

}

1.16.7 锁超时

tryLock()ReentrantLock 类提供的一个方法,用于尝试获取锁,但不会阻塞当前线程。如果锁是可用的,tryLock() 方法会立即获取锁并返回 true,否则会返回 false,表示当前线程无法获取锁。

这个方法的一大优势是可以避免线程在等待锁时被长时间阻塞,因此适用于一些需要灵活处理锁的场景,比如避免死锁,避免线程饥饿等。

tryLock() 方法有多个重载形式:

  1. 无参数版本:
boolean tryLock()

尝试获取锁,如果锁可用则立即返回 true,否则返回 false

  1. 带时间限制的版本:
boolean tryLock(long time, TimeUnit unit) throws InterruptedException

尝试获取锁,如果在指定的时间内(time 时间长度,使用 unit 单位表示)内无法获得锁,返回 false。如果在等待期间线程被中断,会抛出 InterruptedException

这里是一个使用 tryLock() 方法的示例:

ReentrantLock lock = new ReentrantLock();
if (lock.tryLock()) {
    try {
        // 获取到锁,执行操作
    } finally {
        lock.unlock();
    }
} else {
    // 锁不可用,可以进行其他处理
}

在使用 tryLock() 方法时需要注意以下几点:

  • 如果在获取锁之后成功执行临界区操作,确保在 finally 块中释放锁,以避免资源泄漏。
  • tryLock() 并不会引起线程阻塞,因此在某些情况下可能会出现 busy-waiting(忙等待)的情况。为了避免过多的 CPU 资源被浪费,可以在 tryLock() 之后使用 Thread.sleep() 等方式进行适当的延迟,以减少忙等待。
  • 当使用带时间限制的版本时,需要注意超时时间的选择,以及可能的后续处理。如果锁在指定的时间内未被获得,需要根据业务逻辑决定如何处理。

总之,tryLock()ReentrantLock 类中的一个有用方法,可以帮助你更好地控制锁的获取,以适应不同的并发需求。

例子:

@Slf4j
public class TestReentratLock {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();

        Thread t1 = new Thread(() -> {
            try {
                log.debug("t1线程 获取到锁");
                //获取锁的超时时间为5s
                if (!lock.tryLock(5, TimeUnit.SECONDS)){
                    log.debug("t1线程已经等待了5s还没有获取到锁 不再等待了");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                log.debug("t1线程 释放锁");
                lock.unlock();
            }
        }, "t1");

        lock.lock();
        log.debug("main线程 获取到锁");
        t1.start();
        Thread.sleep(6000);
        lock.unlock();
        log.debug("main线程 释放锁");
    }

}

输出:

22:04:49.281 [main] DEBUG com.example.javatest.TestReentratLock - main线程 获取到锁
22:04:49.282 [t1] DEBUG com.example.javatest.TestReentratLock - t1线程 获取到锁
22:04:54.284 [t1] DEBUG com.example.javatest.TestReentratLock - t1线程已经等待了5s还没有获取到锁 不再等待了
22:04:54.284 [t1] DEBUG com.example.javatest.TestReentratLock - t1线程 释放锁
Exception in thread "t1" java.lang.IllegalMonitorStateException
    at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
    at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
    at java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:457)
    at com.example.javatest.TestReentratLock.lambda$main$0(TestReentratLock.java:30)
    at java.lang.Thread.run(Thread.java:748)
22:04:55.288 [main] DEBUG com.example.javatest.TestReentratLock - main线程 释放锁

Process finished with exit code 0

lock.tryLock(long timeout, TimeUnit unit)表示,如果该线程已经等待了timeout时常仍然没有获取到锁,那么就会返回false,不再等待了,防止一直阻塞。

ReentrantLock 是 Java 并发编程中一个强大的工具,可以提供更精细的控制和灵活性,但同时也需要更多的注意力来管理锁的获取和释放,以避免死锁等问题。

例子:使用 tryLock 解决哲学家就餐问题

@Slf4j
public class TestReentratLock {
    public static void main(String[] args) throws InterruptedException {
        //就餐
        Chopstick c1 = new Chopstick("1");
        Chopstick c2 = new Chopstick("2");
        Chopstick c3 = new Chopstick("3");
        Chopstick c4 = new Chopstick("4");
        Chopstick c5 = new Chopstick("5");
        new Philosopher("苏格拉底", c1, c2).start();
        new Philosopher("柏拉图", c2, c3).start();
        new Philosopher("亚里士多德", c3, c4).start();
        new Philosopher("赫拉克利特", c4, c5).start();
        new Philosopher("阿基米德", c5, c1).start();
    }

}

/**
 * 筷子类
 */
@Slf4j
class Chopstick extends ReentrantLock {//继承是关键
    String name;

    public Chopstick(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "筷子{" + name + '}';
    }
}

/**
 * 哲学家类
 */
@Slf4j
class Philosopher extends Thread {
    Chopstick left;
    Chopstick right;

    public Philosopher(String name, Chopstick left, Chopstick right) {
        super(name);
        this.left = left;
        this.right = right;
    }

    private void eat() throws InterruptedException {
        log.debug("eating...");
        Thread.sleep(1000);
    }

    @Override
    public void run() {
        while (true) {
            // 获得左手筷子
            if (left.tryLock()) {
                try {
                    // 获得右手筷子
                    if (right.tryLock()) {
                        // 吃饭
                        try {
                            eat();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        } finally {
                            //使用ReentrantLock就必须使用try-finally块
                            // 放下右手筷子
                            right.unlock();
                        }
                    }
                } finally {
                    //使用ReentrantLock就必须使用try-finally块
                    //这样就算没有获得右手筷子也能够释放锁
                    // 放下左手筷子
                    left.unlock();
                }
            }

        }
    }
}

注意这里的tryLock(超时时间)与tryLcok()的区别!!!

lock.tryLock(10, TimeUnit.MILLISECONDS)lock.tryLock() 之间的主要区别在于超时参数的设置。这个超时参数的存在可以帮助防止多个线程同时获取到锁,因为它会在获取锁失败后强制线程等待一段时间再尝试获取锁。

具体来说,如果你使用 lock.tryLock() 而没有超时参数,多个线程可能会在相同的瞬间尝试获取锁,而且由于 tryLock 是非阻塞的,它们都会看到锁是可用的,然后同时尝试获取锁。这种情况下,多个线程可能同时成功获取锁,导致竞争条件。

而当你使用 lock.tryLock(10, TimeUnit.MILLISECONDS) 时,如果一个线程在尝试获取锁时发现锁已经被其他线程占用,它会等待指定的时间(10毫秒),然后再次尝试获取锁。这个等待的过程会导致线程之间的尝试获取锁的时间差异,从而降低了多个线程同时成功获取锁的概率。

虽然使用超时参数的 tryLock 可以在一定程度上减少竞争条件,但它仍然不如标准的 lock 方法可靠,因为在高并发情况下,仍然存在竞争的可能性,只是概率较低。如果需要强制同一时刻只有一个线程能够进入临界区,使用标准的 lock 方法是更可靠的选择。


文章作者: 念心卓
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 念心卓 !
  目录