Loading

18-多线程(上)

1. 相关概念

1.1 程序

程序(program) 是为完成特定任务、用某种语言编写的一组指令的集合。即指一段静态的代码,静态对象。下面谈一下 [程序的两种执行方式]。

1.2 进程

1.2.1 进程的由来

  • 一方面为了保持程序是一个在时间上严格有序的指令集合,是静态的保存在存储介质上这个概念的原有含义,另一方面为了刻画多个程序共同运行时呈现出的这些特征。在 OS 中,以"程序"为基础,又引入了"进程"这一新的概念。
  • 为了使程序能并发执行,且为了对并发执行的程序加以描述,所以人们引入了"进程"。

1.2.2 进程的定义

  • 进程是一个正在执行的程序的实例。进程不断地被创建和撤销,当用户调用一个程序时,进程被创建;一旦程序执行完毕,进程被撤销。所以,进程可以被定义成一个 [动态实体]。
  • 进程是程序的动态执行,进程是程序在一个数据集合上运行的过程
  • 进程作为资源分配的单位,系统在运行时会为每个进程分配不同的内存区域。

1.2.3 程序和进程的区别

  • 程序是指令的集合,是静态的;进程是程序在处理机上的一次执行的过程,是动态的。
  • 程序可以作为软件资料长期保存;进程是有生命周期的。
  • 进程是一个独立的运行单位,能与其他进程并发活动;而程序不是。
  • 进程是竞争计算机系统有限资源的基本单位,也是进行处理机调度的基本单位
  • 一个程序可以作为多个进程的运行程序;一个进程也可以运行多个程序。
  • 举例:一个视频文件(程序),播放视频的活动(进程)

1.2.4 进程的分类和组成

  • 在系统中同时有多个进程存在,但归纳起来有两大类:
    • 系统进程:执行 OS 核心代码的进程(起着资源管理和控制的作用)
    • 用户进程:执行用户程序的进程
  • 区别
    • 系统进程被分配一个初始的资源集合,这些资源可以为它独享,也能以最大优先权的资格使用。用户进程通过系统服务请求的手段竞争使用系统资源
    • 用户进程不能直接做 I/O 操作,而系统进程可以做显示的、直接的 I/O 操作
    • 系统进程在 [管态] 下活动,而用户进程则在 [用户态(目态)] 下活动
  • 组成:程序段、数据段 和 进程控制块(PCB)

为了描述和控制进程的运行,系统为每个进程定义了一个数据结构 —— 进程控制块 PCB。PCB 描述进程状态以及控制所需信息,系统根据 PCB 感知进程的存在,并对它进行控制,因此,PCB 是进程存在的唯一标志。由于 PCB 要被系统频繁的访问,它必须常驻内存。

1.2.5 OS 管理进程

  • 把处于相同状态的进程的 PCB,通过各自的队列指针连接在一起,形成一个个队列
  • 为每一个队列设立一个队列头指针,它总是指向排在队列之首的进程的 PCB
  • 排在队尾的进程的 PCB,它的"队列指针"项内容应该为"-1"

1.3 线程

进程可进一步细化为线程,是一个程序内部的一条执行路径。

以前所编写的程序,都是单线程的(main);每个程序都有一个入口,一个出口以及一个顺序执行的序列,在程序执行过程中的任何指定时刻,都只有一个单独的执行点。

所谓的【多线程】,就是一个程序运行时有多条不同的执行路径。

线程作为调度和执行的单位,每个线程拥有独立的运行栈和程序计数器(pc),线程切换的开销小。

一个进程中的多个线程共享相同的内存单元/内存地址空间,它们从同一堆中分配对象,可以访问相同的变量和对象。这就使得线程间通信更简便、高效。但多个线程操作共享的系统资源可能就会带来安全的隐患。

单核 CPU,其实是一种假的多线程,因为在一个时间单元内,也只能执行一个线程的任务。如果是多核的话,才能更好的发挥多线程的效率。(现在的服务器都是多核的)

一个 Java 应用程序 java.exe,其实至少有 3 个线程:main() 主线程、gc() 垃圾回收线程 和 异常处理线程。// 如果发生异常,会影响主线程

2. 线程的创建和使用

JVM 允许程序运行多个线程,它通过 java.lang.Thread 类来体现

Thread 类的特性:

2.1 声明为 Thread 的子类

2.1.1 启动线程的步骤

  1. 创建一个类,声明为 Thread 的子类
  2. 重写 Thread 的 run 方法
  3. 创建该子类对象
  4. 通过此对象调用 start 方法

2.1.2 Thread 类的特性

每个线程都是通过某个特定 Thread 对象的 run() 来完成操作的,经常把 run() 的主体称为线程体

通过该 Thread 对象的 start() 来启动这个线程,而非直接调用 run();直接调用 run(),只会执行同一个线程中的任务,而不会启动新线程。应该调用 Thread.start()。这个方法将创建一个执行 run() 的新线程。

执行完 start() 后并不代表 Thread 对象所对应的线程就一定会立即得到执行,调用过 start() 只是表达该线程具有了可以立即被 CPU 执行的资格(线程属于 [就绪状态])。但由于想抢占 CPU 执行的线程有很多,CPU 并不一定会立即去执行 Thread 对象所对应的线程。

2.1.3 案例

// 遍历1000以内的所有整数
public class ThreadDemo {
    // 主线程
    public static void main(String[] args) {
        // 3. 创建子类对象(主线程造的)
        MyThread_1 t1 = new MyThread_1();
        // 4. 通过子类对象调用start()
        // 作用:① 启动当前线程 ② JVM 调用当前线程的run()
        t1.start();
        // t1.run(); // 直接调用run(), 就是纯粹的方法调用, 不会启动新的线程
        // Quiz:想要再启动一个线程,打印1~1000,怎么做?
        /*
        t1.start(); // 错误方式!
        Exception in thread "main" java.lang.IllegalThreadStateException
        -------------------------
        start() 部分源码:
            if (threadStatus != 0) throw new IllegalThreadStateException();
        */
        // 解决方法:重新创建一个线程的对象
        MyThread_1 t2 = new MyThread_1();
        t2.start();
        for (int i = 0; i < 1000; i++)
            System.out.println("主线程:"+i);
    }
}

// 1. 创建继承Thread的子类
class MyThread_1 extends Thread {
    // 2. 重写run()
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++)
            System.out.println(Thread.currentThread().getName() + ":" + i);
    }
}

// 创建两个分线程,其中一个遍历100以内的偶数,另一个遍历100以内的奇数
public class ThreadTest {
    public static void main(String[] args) {
        // new对象和调用start()是main线程做的

        // 创建 Thread 类的匿名子类
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++)
                    if(i % 2 != 0)
                        System.out.println(Thread.currentThread().getName() + ":" + i);
            }
        }.start();

        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++)
                    if(i % 2 == 0)
                        System.out.println(Thread.currentThread().getName() + ":" + i);
            }
        }.start();
    }
}

想要启动多线程,必须调用 start()。如果自己手动调用 run(),那么就只是普通方法,没有启动多线程模式。

run() 由 JVM 调用,什么时候调用,执行的过程控制都有操作系统的 CPU 调度决定。

一个线程对象只能对应一个线程,也就是说只能调用一次 start() 启动该线程,如果重复调用则将抛出以上的异常IllegalThreadStateException

2.2 线程的控制

2.2.1 线程常见方法

  • public static Thread currentThread() 返回对当前正在执行的线程对象的引用
  • public final void setName(String name) 设置当前线程的名字
  • public final String getName() 返回当前线程的名字
  • public final boolean isAlive() 判断线程是否处于活动状态。如果线程已经启动且尚未终止,则为活动状态
  • public static void sleep(long millis) throws InterruptedException 在指定的毫秒数内让当前正在执行的线程休眠
  • public final void join(long millis) throws InterruptedException 在 Thread-A 的线程体中调用 Thread-B 的 join(),将 Thread-A 和 Thread-B 进行"合并",等待 Thread-B 结束,再恢复 Thread-A 的运行 // 暂停的不是 Thread-B,而是调用 b.join() 的线程,即 Thread-A
  • public static void yield() 让出 CPU 使用权,当前线程进入就绪队列等待调度
  • public final void stop() 已过时。强迫线程停止执行。应使用 interrupt() 来中断该线程
  • public final void wait() throws InterruptedException 在其他线程调用此对象的 notify() 方法或 notifyAll() 方法前,导致当前线程等待(当前线程进入对象监视器的 WaitSet)
  • public final void notify() 唤醒在此对象监视器上等待的单个线程。如果有多个线程在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。
  • public final void notifyAll() 唤醒在此对象监视器上等待的所有线程。

注:wait() / notify() / notifyAll() 声明在 Object

2.2.2 线程优先级

Java 提供一个线程调度器来监控程序中启动后进入就绪状态的所有线程。线程调度器按照线程的优先级决定应调用哪个线程来执行。

现成的优先级用数字表示,范围从 1 到 10,一个线程的缺省优先级是 5:

public final static int MIN_PRIORITY = 1;
public final static int NORM_PRIORITY = 5;
public final static int MAX_PRIORITY = 10;

使用下述方法获得/设置线程对象的优先级:

public final int getPriority()
public final void setPriority(int newPriority)

通常高优先级的线程将先于优先级低的线程执行,但并不是说要等高优先级线程执行完之后才会执行低优先级线程,只是从概率上讲,高优先级线程抢占到 CPU 使用权的概率会更大,仅此而已。

2.3 声明实现 Runnable 接口的类

2.3.1 启动线程的步骤

  1. 创建一个类,实现了 Runnable 接口
  2. 实现类实现 Runnable 接口中的抽象方法:run()
  3. 创建实现类的对象
  4. 将此实现类的对象作为参数传递给 Thread 类的带参构造器,创建 Thread 类的对象
  5. 通过该 Thread 类对象调用 start()
public class ThreadDemo1 {
    public static void main(String[] args) {
        MyThread_2 r = new MyThread_2();
        Thread t1 = new Thread(r);
        // 1. 启动线程 2. 调用当前线程的run() → 调用target的run()
        t1.start();

        // 再启动一个线程(共享同一个Runnable)
        Thread t2 = new Thread(r);
        t2.start();
    }
}

class MyThread_2 implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            if (i % 2 == 0)
                System.out.println(Thread.currentThread().getName() + ":" + i);
        }
    }
}

2.3.2 Thread 底层源码

public class Thread implements Runnable {

    // What will be run
    private Runnable target;

    public Thread(Runnable target) {
        ...
        this.target = target;
        ...
    }

    ...

    @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }
}

2.3.3 比较创建线程的两种方式

  • 开发中优先选择实现 Runnable 接口的方式
    • 实现的方式没有类单继承性的局限性
    • 实现的方式更适合来处理多个线程有共享数据的情况
  • 联系
    • public class Thread implements Runnable {...}
    • 两种方式都需要重写 run(),将线程要执行的逻辑声明在 run() 中

2.4 线程的分类

Java 中的线程分为两类:一种是守护线程,一种是用户线程

  • 它们在几乎每个方面都是相同的,唯一的区别是判断 JVM 何时离开
  • 守护线程是用来服务用户线程的,通过在 start() 前调用 thread.setDaemon(true) 可以把一个 [用户线程] 变成一个 [守护线程]。
  • Java 垃圾回收就是一个典型的守护线程
  • 若 JVM 中都是守护线程,当前 JVM 将退出
  • 形象理解:兔死狗烹,鸟尽弓藏

3. 线程的生命周期

JDK 中用 Thread.State 类来定义线程状态:public static enum Thread.State extends Enum<Thread.State>

要想实现多线程,必须在主线程中创建新的线程对象。Java 语言使用 Thread 类及其子类的对象来表示线程,在它的一个完整的生命周期中通常要经历如下的 5 种状态:

  • 创建: 当一个 Thread 类或其子类的对象被声明并创建时,新生的线程对象处于新建状态
  • 就绪:有多种抵达就绪态的情况
    • 处于新建状态的线程被 start() 后,此线程进入就绪状态
    • 当前线程 sleep() 结束,其他线程 join(),等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态
    • 当前线程时间片用完了,调用当前线程的 yield(),当前线程进入就绪状态
    • [锁池] 里的线程拿到对象锁后,进入就绪状态
    • 该状态的线程位于 [可运行线程池] 中,变得可运行、只等待获取 CPU 的使用权,即在就绪状态的进程除 CPU 之外,其他的运行所需资源都已全部获得
  • 运行:当就绪的线程被调度并获得 CPU 资源时,便进入运行状态, run() 定义了线程的操作和功能
  • 阻塞:阻塞状态是线程因为某种原因放弃 CPU 使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态
    • 等待阻塞:运行的线程执行 wait(),该线程会释放占用的所有资源,JVM 会把该线程放入 [等待池] 中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用 notify()notifyAll() 才能被唤醒。
    • 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则 JVM 会把该线程放入 [锁池] 中。
    • 其他阻塞:运行的线程执行 sleep()join(),或者发出了 I/O 请求时,JVM 会把该线程置为阻塞状态。当 sleep() 状态超时、join() 等待线程终止或者超时、I/O 处理完毕时,线程重新转入就绪状态。
  • 死亡:线程完成了它的全部工作或线程被提前强制性地中止或出现异常导致结束

在给定时间点上,一个线程只能处于一种状态。这些状态是虚拟机状态,它们并没有反映所有操作系统线程状态。

补充:线程的挂起(suspend) 和恢复(resume)


4. 线程的同步

4.1 问题的提出

4.1.1 问题演示

  • 用 Thread 子类演示
    // 存在线程安全问题,待解决
    public class SellTicket {
        public static void main(String[] args) {
            Window w1 = new Window();
            w1.setName("window-1");
            Window w2 = new Window();
            w2.setName("window-2");
            Window w3 = new Window();
            w3.setName("window-3");
            w1.start();
            w2.start();
            w3.start();
        }
    }
    
    /*
    控制台:
    window-2销售出第100张票
    window-3销售出第100张票
    window-1销售出第100张票
    window-3销售出第97张票
    window-2销售出第97张票
    window-1销售出第95张票
    window-3销售出第94张票
    ...
    window-3销售出第1张票
    window-1销售出第0张票
    window-2销售出第0张票
     */
    
    class Window extends Thread {
        private static int tickets = 100;
    
        @Override
        public void run() {
            while(tickets > 0) {
                try {
                    sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(getName() + "销售出第" + tickets + "张票");
                tickets--;
            }
        }
    }
    
  • 用 Runnable 实现类演示
    public class SellTicket1 {
        public static void main(String[] args) {
            Window1 w = new Window1();
            Thread t1 = new Thread(w);
            Thread t2 = new Thread(w);
            Thread t3 = new Thread(w);
            t1.start();
            t2.start();
            t3.start();
        }
    }
    
    class Window1 implements Runnable {
        // 无须 static
        private int tickets = 100;
    
        @Override
        public void run() {
            while(tickets > 0) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()
                        + "销售出第" + tickets + "张票");
                tickets--;
            }
        }
    }
    

4.1.3 分析问题

  • 根据控制台的打印情况可知,出现了重票、错票 → 线程安全问题
  • 问题的原因:当某个线程操作车票的过程中,且尚未操作完成,仅执行了一部分;此时其他线程参与进来,也来操作车票。导致共享数据的错误。
  • 如何解决:在一个线程操作 tickets 的时候,其他线程不允许参与进来,直到操作线程操作完 tickets 后,其他线程才可以操作 tickets;即使操作线程在操作过程中出现了阻塞,其他线程也不允许进来,必须要等操作线程操作完毕。

4.2 解决问题的方式

  • 引入锁
    • Java对于多线程的安全问题提供了专业的解决方式:同步机制
    • 在Java语言中,还引入了对象互斥锁的概念,保证共享数据操作的完整性,每个对象都对应一个可称为“互斥锁的标记”,这个标记保证在任一时刻,只能有一个线程访问该对象,故也将该对象称之为 "同步锁" / "同步监视器"。
    • 关键字 synchronized 来与对象的互斥锁联系,当某个对象被 synchronized 修饰时,说明该对象在任一时刻只能由一个线程访问。
  • 同步锁机制
    • 对于并发工作,你需要某种方式来防止两个任务访问相同的资源(其实就是共享资源竞争)。 防止这种冲突的方法就是当资源被一个任务使用时,在其上加锁。
    • 第一个访问某项资源的任务必须锁定这项资源,使其他任务在其被解锁之前,就无法访问它了,而在其被解锁之时,另一个任务就可以锁定并使用它了。
  • synchronized 锁的是什么?
    • 任意对象都可以作为同步锁。所有对象都自动含有单一的锁(监视器 ObjectMonitor)
    • 同步方法的锁:静态方法(类名.class)、非静态方法(this)
    • 同步代码块的锁:很多时候也是指定为 this类名.class
  • 注意
    • 必须确保使用同一个资源的多个线程共用一把锁,这个非常重要,否则就无法保证共享资源的安全。
    • 一个线程类中的所有静态方法共用同一把锁(类名.class),所有非静态方法共用同一把锁(this),同步代码块(指定需谨慎)

4.2.1 同步代码块

synchronized(同步监视器) {
    // 操作 [共享数据] 的代码,即为需要被同步的代码
    // 共享数据:多个线程共同操作的数据(变量),比如例子中的tickets
}
  • Runnable的实现类
    @Override
    public void run() {
        while(true) {
            synchronized(this) {
                if (tickets > 0) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName()
                            + "销售出第" + tickets + "张票");
                    tickets--;
                } else break;
            }
        }
    }
    
  • Thread的子类
    @Override
    public void run() {
        while(true) { // Class clazz = Window.class; 且是唯一的
            synchronized(Window.class) {
                if(tickets > 0) {
                    try {
                        sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(getName() + "销售出第" 
                        + tickets + "张票");
                    tickets--;
                } else break;
            }
        }
    }
    
  • 如何找问题,即代码是否存在线程安全?
    • 明确哪些代码是多线程运行的代码
    • 明确多个线程是否有共享数据
    • 明确多线程运行代码中是否有多条语句操作共享数据
  • 如何解决呢?
    • 对多条操作共享数据的语句,只能让一个线程都执行完,在执行过程中,其他线程不可以参与执行
    • 即所有操作共享数据的这些语句都要放在同步范围中
  • 同步的范围
    • 范围太小:没锁住所有有安全问题的代码
    • 范围太大:没发挥多线程的功能

4.2.2 同步方法

synchronized 还可以放在方法声明中,表示整个方法为同步方法。同步方法仍然涉及到同步监视器,只是不需要显式声明。

  • 非静态方法的同步监视器默认是this
    private synchronized void show() {
        if (tickets > 0) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName()
                    + "销售出第" + tickets + "张票");
            tickets--;
        }
    }
    
  • 静态方法的同步监视器默认是 类名.class
    private static synchronized void show() {
        if(tickets > 0) {
            try {
                sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName()
                + "销售出第" + tickets + "张票");
            tickets--;
        }
    }
    

若类中有多个非静态同步方法,则这些同步方法共有一把锁;所以,当其中某个方法被调用,即对象的锁被锁住,则不会有其他线程可以调用同一个类的这个或任何其他的同步方法。注意,是只有使用同一个锁对象的线程才会受到这个影响,如果是多个锁对象,则不受影响,各是各的。但如果是静态同步方法,也就是"类锁",那这就一定是所有线程共享的锁。

4.3 解决懒汉式线程安全问题

class Bank {
    private Bank() {}

    private static volatile Bank instance;

    // public static synchronized Bank getInstance() {
    public static Bank getInstance() {
        /*
        // 效率低
        synchronized (Bank.class) {
            if(instance == null)
                instance = new Bank();
            return instance;
        }
        */
        if(instance == null) {
            synchronized (Bank.class) {
                if(instance == null)
                    instance = new Bank();
            }
        }
        return instance;
    }
}

4.4 线程的死锁问题

在 Java 中,死锁(Deadlock)是一个常见的并发问题,它发生在两个或多个线程相互等待对方释放资源,从而造成程序无法继续执行。理解死锁的产生机制有助于避免和解决这一问题。

4.4.1 死锁的产生条件

根据计算机科学理论,死锁发生通常需要满足以下四个必要条件:

  1. 互斥条件(Mutual Exclusion):至少有一个资源必须处于非共享模式,即一个资源每次只能由一个线程持有。如果另一个线程请求该资源,则请求线程必须等待直到资源被释放。
  2. 占有且等待条件(Hold and Wait):一个线程已经持有至少一个资源,但又请求其他线程持有的资源,并且这些资源不能被抢占。
  3. 非抢占条件(No Preemption):资源不能被强制从一个线程中夺走,只能由持有该资源的线程主动释放。
  4. 循环等待条件(Circular Wait):存在一种线程资源的循环等待关系,其中每个线程都在等待另一个线程释放它所需的资源,从而形成一个环形等待链。

4.4.2 死锁产生的示例

public class DeadlockExample {

    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1: Holding lock 1...");
                try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }
                System.out.println("Thread 1: Waiting for lock 2...");
                synchronized (lock2) {
                    System.out.println("Thread 1: Acquired lock 2!");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2: Holding lock 2...");
                try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }
                System.out.println("Thread 2: Waiting for lock 1...");
                synchronized (lock1) {
                    System.out.println("Thread 2: Acquired lock 1!");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

在这个示例中,两个线程 thread1thread2 分别尝试获取两个锁(lock1lock2),但它们的锁获取顺序相反。这就导致了以下情况:

  • thread1 持有 lock1 并等待 lock2
  • thread2 持有 lock2 并等待 lock1

由于 thread1thread2 互相等待对方释放锁,因此形成了一个死锁,程序在两个线程的 synchronized 块中停滞不前。

4.4.3 避免死锁的方法

  1. 避免嵌套锁:尽量避免一个线程在持有一个锁的情况下再去获取其他锁。
  2. 锁的顺序:确保所有线程获取锁的顺序一致。例如,如果多个线程需要获取 lock1lock2,确保所有线程按相同的顺序获取锁。
  3. 超时机制:使用锁的超时机制,如 tryLock 方法,它允许线程在无法获取锁的情况下放弃尝试,从而避免死锁。
  4. 减少锁的持有时间:尽量缩短持有锁的时间,避免在持有锁的情况下执行长时间的操作。
  5. 使用更高层次的并发工具:Java的 java.util.concurrent 包提供了一些高级的并发工具和机制(如 ReentrantLockSemaphoreCountDownLatch),它们可以帮助更好地管理线程和资源,减少死锁的风险。

4.5 Lock

4.5.1 锁对象

从 JDK 5.0 开始,Java 提供了更强大的线程同步机制 —— 通过显式定义〈同步锁〉对象来实现同步。同步锁使用 Lock 对象充当。

java.util.concurrent.locks.Lock 接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,线程开始访问共享资源之前应先获得 Lock 对象。

ReentrantLock 类实现了 Lock ,它拥有与 synchronized 相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是 ReentrantLock,可以显式加锁、释放锁。

  • java.util.concurrent.locks.Lock<I>
    • void lock() 获取这个锁;如果锁同时被另一个线程拥有则发生阻塞
    • void unlock() 释放这个锁
  • public class ReentrantLock extends Object implements Lock, Serializable
    • ReentrantLock() 构建一个可以被用来保护临界区的可重入锁
    • ReentrantLock(boolean fair) 构建一个带有公平策略的锁。一个公平锁偏爱等待时间最长的线程。但是,这一公平的保证将大大降低性能。所以,默认情况下,锁没有被强制为公平的。

听起来公平锁更合理一些,但是使用公平锁比使用常规所要慢很多。只有当你确实了解自己要做什么并且对于你要解决的问题有一个特定的里有必须使用公平锁的时候,才可以使用公平锁。即使使用公平锁,也无法确保线程调度器是公平的。如果线程调度器选择忽略一个线程,而该线程为了这个锁已经等待了很长时间,那么就没有机会公平地处理这个锁了。

ReentrantLock 保护代码块的基本结构如下:

myLock.lock(); // a ReentrantLock object
try {
    // critical section
} finally {
    myLock.unlock(); // 确保即使发生异常,也会释放锁
}

这个结构确保任何时刻只有一个线程进入临界区。一旦一个线程封锁了锁对象,其他任何线程都无法通过 lock 语句。当其他线程调用 lock() 时,他们被阻塞,直到第一个线程释放锁对象。

注意!把解锁操作括在 finally 子句之内是至关重要的。如果在临界区的代码抛出异常,锁必须被释放。否则,其他线程将永远阻塞。

使用一个锁来保护 Bank 类的 transfer 方法:

public class Bank {
    private Lock bankLock = new ReentrantLock(); // ReentrantLock implements the Lock<I>
    ...
    public void transfer(int from, int to, int amount) {
        bankLock.lock();
        try {
            System.out.println(Thread.currentThread().getName());
            accounts[from] -= amount;
            System.out.println(" %10.2f from %d to %d", amount, from, to);
            accounts[to] += amount;
            System.out.println(" Total Balance: " + getTotalBalance());
        } finally {
            bankLock.unlock();
        }
    }
}
  • 假定一个线程调用了 transfer(),在执行结束前被剥夺了运行权。假定第二个线程也调用了 transfer(),由于第二个线程不能获得锁,将在调用 lock() 时被阻塞。它必须等待第一个线程完成 transfer() 方法的执行之后才能再度被激活。当第一个线程释放锁时,那么第二个线程才能开始运行。
  • 每一个 Bank 对象都有自己的 ReentrantLock 对象。如果两个线程试图访问同一个 Bank 对象,那么锁将以串行方式提供服务。但是,如果两个线程访问不同的 ReentrantLock 对象,每一个线程得到不同的锁对象,两个线程都不会发生阻塞。本该如此,因为线程在操作不同的 Bank 实例的时候,线程之间不会相互影响。
  • 锁是可重入的。因为线程可以重复地获得已经持有的锁。锁保持一个持有计数(hold count) 来跟踪对 lock() 的嵌套调用。线程在每一个调用 lock() 都要调用 unlock() 来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用使用相同的锁的方法。例如,Banktransfer() 中还会调用 getTotalBalance(),这也会封锁 bankLock 对象,此时,bankLock 对象的持有计数为 2。当 getTotalBanlance() 退出的时候,持有计数变回 1。当 transfer() 退出的时候,持有计数变为 0。线程释放锁。
  • 非同步线程与同步线程的比较

要留心临界区中的代码,不要因为异常的抛出而跳出了临界区。如果在临界区代码结束之前抛出了异常,finally子句将释放锁,但会是对象可能处于一种受损状态。

4.5.2 条件对象

通常,线程进入临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。本节将介绍 Java 库中条件对象的实现(条件对象经常被称为条件变量)

// 下面直接截图吧。。。 我太累了


  • API
    • java.util.concurrent.locks.Lock
      • Condition newCondition() 返回一个与该锁相关的条件对象
    • java.util.concurrent.locks.Condition
      • void await() 将该线程放到条件的等待集中
      • void signalAll() 解除该条件的等待集中的所有线程的阻塞状态
      • void signal() 从该条件的等待集中随机地选择一个线程,解除其阻塞状态
  • 下面总结一下有关锁和条件的关键之处:
    • 锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码
    • 锁可以管理试图进入被保护代码段的线程
    • 锁可以拥有一个或多个相关的条件对象
    • 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程
  • synchronized 和 lock 的对比?
    • Lock 是显式锁(手动开启和关闭锁),synchronized 是隐式锁,出了作用域自动释放。
    • 内部对象锁(synchronized) 只有一个相关条件。wait() 添加一个线程到等待集中,notifyAll() / notify() 解除等待线程的阻塞状态。换句话说,调用 obj.wait() ~ condition.await()obj.notifyAll() ~ condition.signalAll()
    • Lock 只有代码块锁,synchronized 有代码块锁和方法锁
    • 使用 Lock 锁,JVM 将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)。
    • 优先使用顺序: Lock → 同步代码块(已经进入了方法体,分配了相应资源)→ 同步方法 (在方法体之外)

4.6 练习

银行有一个账户。 有两个储户分别向同一个账户存 3000 元,每次存 1000,存 3 次。每次存完打印账户余额。问题:该程序是否有安全问题,如果有,如何解决?

public class AccountTest {
    public static void main(String[] args) {
        Account acct = new Account();
        Customer c1 = new Customer(acct);
        Customer c2 = new Customer(acct);
        c1.setName("甲");
        c2.setName("乙");
        c1.start();
        c2.start();
    }
}

/*
打印控制台(存在线程安全问题):
    乙存入1000.0, 当前余额:2000.0
    甲存入1000.0, 当前余额:2000.0
    乙存入1000.0, 当前余额:4000.0
    甲存入1000.0, 当前余额:4000.0
    乙存入1000.0, 当前余额:6000.0
    甲存入1000.0, 当前余额:6000.0
---------------------------------
加入同步机制后:
    甲存入1000.0, 当前余额:1000.0
    甲存入1000.0, 当前余额:2000.0
    甲存入1000.0, 当前余额:3000.0
    乙存入1000.0, 当前余额:4000.0
    乙存入1000.0, 当前余额:5000.0
    乙存入1000.0, 当前余额:6000.0
 */


class Account {
    private double balance;

    public Account() {}

    public Account(double balance) {
        this.balance = balance;
    }

    // public void deposit(double amt) {
    public synchronized void deposit(double amt) {
        // 前面说过,在使用继承Thread类来创建多线程的方式时,慎用this
        // 充当同步监视器,考虑使用'当前类.class'来充当同步监视器
        // 这个慎用不是不通用,而是要具体问题具体分析,比如这里就可以用
        // 此时的this,不是Customer对象,而是其共用的Account,它是唯一的
        if(amt > 0) {
            balance += amt;

            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            System.out.println(Thread.currentThread().getName()
                    + "存入" + amt + ", 当前余额:" + balance);
        }
    }
}

class Customer extends Thread {
    private Account acct;

    public Customer(Account acct) {
        this.acct = acct;
    }

    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            acct.deposit(1000);
        }
    }
}

4.7 释放/不释放锁的操作

  • 释放锁的操作
    • 当前线程的同步方法、同步代码块执行结束
    • 当前线程在同步代码块、同步方法中遇到 breakreturn 终止了该代码块、 该方法的继续执行
    • 当前线程在同步代码块、同步方法中出现了未处理的 ErrorException,导致异常结束
    • 当前线程在同步代码块、同步方法中执行了线程对象的 wait(),当前线程暂停,并释放锁
  • 不释放锁的操作
    • 线程执行同步代码块或同步方法时,程序调用 Thread.sleep()Thread.yield() 暂停当前线程的执行
    • 线程执行同步代码块时,其他线程调用了该线程的 suspend() 将该线程挂起,该线程不会释放锁(同步监视器)// 应尽量避免使用 suspend()resume() 来控制线程

5. 线程的通信

  • wait(): 一旦执行此方法,当前线程就会进入阻塞状态;释放同步监视器,然后进入等待
  • notify(): 唤醒在此同步监视器上等待的所有线程;若有多个,则会选择唤醒其中一个线程
  • notifyAll(): 唤醒在此同步监视器上等待的所有线程
public class Demo {
    public static void main(String[] args) {
        Number n = new Number();
        Thread t1 = new Thread(n);
        Thread t2 = new Thread(n);
        t1.setName("线程1");
        t2.setName("线程2");
        t1.start();
        t2.start();
    }
}

class Number implements Runnable {
    private int number = 1;
    // private Object obj = new Object();

    @Override
    public void run() {
        while(true) {
            // synchronized (obj) {
            synchronized (this) {
                // obj.notify();
                notify();
                if(number <= 10) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + ":" + number);
                    number++;
                    try {
                        System.out.println(Thread.currentThread().getName()+"将被阻塞");
                        wait(); // 使得调用该方法的线程进入阻塞状态;且线程会释放锁
                        // obj.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 在当前线程被notify后,要重新获得监控权,然后从断点处继续代码的执行
                    System.out.println(Thread.currentThread().getName()
                            + "被唤醒后,继续从这里执行");
                } else break;
            }
        }
    }
}
  1. 上述 3 个方法必须在 同步代码块/同步方法 中使用
  2. 这 3 个方法都定义在 Object 类中;前面说过同步监视器只要是个对象就行,所以这些方法在 Object 类中声明最合适。
  3. synchronized (obj) {notify(); ... } 会抛异常:IllegalMonitorStateException;要求这 3 个方法的调用者必须是同步监视器
  4. 若方法调用前省略调用者(默认为 this),则 this 必须得作为同步监视器!

sleep()wait() 的异同?

  • 都可以使得当前线程进入阻塞状态;
  • 两个方法定义的位置不同:Thread 类中定义了 sleep(),Object 类中定义了 wait();
  • 调用时满足的要求不同:sleep() 没有要求;wait() 必须在同步代码块 / 同步方法中被调用;
  • 关于是否释放同步监视器:若都使用在同步代码块 / 同步方法中,sleep() 不会释放同步监视器,wait() 会释放。

6. 生产者/消费者问题

生产者(Productor) 将产品交给店员(Clerk),而消费者(Customer) 从店员处取走产品,店员一次只能持有固定数量的产品(比如:20),如果生产者试图生产更多的产品,店员会叫生产者停一下,如果店中有空位放产品了再通知生产者继续生产;如果店中没有产品了,店员会告诉消费者等一下,如 果店中有产品了再通知消费者来取走产品。

/*
1. 是否是多线程问题?是,生产者线程,消费者线程
2. 是否有线程安全问题?是,共享数据为 [商品数目]
3. 如何解决线程安全问题?同步机制
4. 是否涉及到线程的通信?是,wait()/notify()
 */

class Clerk {
    private int productCount;
    private final int MAX_COUNT = 20;

    // 如下两个同步方法共用一个对象监视器, 即 Clerk.this

    public synchronized void produceProduct() {
        if(productCount < MAX_COUNT) {
            productCount++;
            System.out.println(Thread.currentThread().getName() + "生产第" + productCount + "个产品");
            notifyAll();
        } else {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public synchronized void consumeProduct() {
        if(productCount > 0) {
            System.out.println(Thread.currentThread().getName() + "消费第" + productCount + "个产品");
            productCount--;
            notify();
        } else {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class Consumer extends Thread {
    private Clerk clerk;

    public Consumer(Clerk clerk) {
        this.clerk = clerk;
    }

    @Override
    public void run() {
        while(true) {
            System.out.println(getName() + "消费中...");
            try {
                Thread.sleep(130);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            clerk.consumeProduct();
        }
    }
}

class Producer extends Thread {
    private Clerk clerk;

    public Producer(Clerk clerk) {
        this.clerk = clerk;
    }

    @Override
    public void run() {
        while(true) {
            System.out.println(getName() + "生产中...");
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            clerk.produceProduct();
        }
    }

}

public class ProductTest {
    public static void main(String[] args) {
        Clerk clerk = new Clerk();
        Producer p = new Producer(clerk);
        Consumer c1 = new Consumer(clerk);
        Consumer c2 = new Consumer(clerk);
        p.setName("生产者");
        c1.setName("1号消费者");
        c2.setName("2号消费者");
        p.start();
        c1.start();
        c2.start();
    }
}

7. JDK5.0 新增线程创建方式

7.1 实现 Callable 接口

  • 与使用 Runnable 相比, Callable 功能更强大些
    • 相比 run(),可以有返回值
    • 方法可以抛出异常
    • 支持泛型的返回值
    • 需要借助 FutureTask 类,比如获取返回结果
  • 涉及到的类/接口
    • public interface Callable<V> 返回结果并且可能抛出异常的任务。实现者定义了一个不带任何参数的叫做 call 的方法
    • public interface Future<V> Future 表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并获取计算的结果。计算完成后只能使用 get 方法来获取结果
    • public interface RunnableFuture<V> extends Runnable, Future<V> 作为 Runnable 的 Future。成功执行 run 方法可以完成 Future 并允许访问其结果
    • public class FutureTask<V> extends Object implements RunnableFuture<V> 可取消的异步计算。利用开始和取消计算的方法、查询计算是否完成的方法和获取计算结果的方法,此类提供了对 Future 的基本实现。仅在计算完成时才能获取结果;如果计算尚未完成,则阻塞 get 方法。一旦计算完成,就不能再重新开始或取消计算
  • 线程创建过程
    1. 创建一个 Callable<I> 的实现类,实现 call(),将此线程需要执行的操作声明在 call()
    2. 创建 Callable<I> 实现类对象
    3. 将此对象作为形参传递到 FutureTask 构造器:public FutureTask(Callable<V> callable),创建 FutureTask 对象
    4. FutureTask 对象作为参数传递到 Thread 构造器(FutureTask 实现的接口继承了 Runnable) 中,创建 Thread 对象
    5. 调用 Thread 对象的 start() 启动线程
    6. 可通过 FutureTask 对象的 get() 获取 call() 的返回值
  • 举例
    class NewThread implements Callable {
        @Override
        public Object call() throws Exception {
            int sum = 0;
            for (int i = 0; i < 100; i++) {
                if(i %  2 == 0) {
                    System.out.println(i);
                    sum += i;
                }
            }
            return sum;
        }
    }
    
    public class CallableDemo {
        public static void main(String[] args) {
            NewThread nt = new NewThread();
            FutureTask ft = new FutureTask(nt);
            new Thread(ft).start();
            try {
                Object sum = ft.get();
                System.out.println("总和为:" + sum);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
        }
    }
    

7.2 使用线程池

7.2.1 概述

经常创建和销毁、使用量特别大的资源,比如并发情况下的线程, 对性能影响很大。提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁创建销毁、实现重复利用。

使用线程池的好处:① 提高响应速度(减少了创建新线程的时间);② 降低资源消耗(重复利用线程池中线程,不需要每次都创建);③ 便于线程管理(核心池的大小、最大线程数、线程没有任务时最多保持多长时间后会终止 ...)

7.2.2 线程池

7.2.3 相关 API

  • 执行器(Executors) 类有许多静态工厂方法用来构建线程池
    • Executors.newCachedThreadPool():必要时创建新线程;空闲线程会被保留 60s
    • Executors.newFixedThreadPool(n):该池包含固定数量的线程;空闲线程会一直被保留
    • Executors.newSingleThreadExecutor():只有一个线程的"池",该线程顺序执行每一个提交的任务
    • Executors.newScheduledThreadPool(n):创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行
  • ExecutorService:真正的线程池接口,也是上述构建线程池方法的返回类型
    • void execute(Runnable command):执行任务/命令,没有返回值,一般用来执行 Runnable
    • <T> Future<T> submit(Callable<T> task):执行任务,有返回值,一般用来执行 Callable
    • void shutdown():关闭连接池
  • ThreadPoolExecutor:ExecutorService 的子类,也是上述构建线程池方法的实际返回类型
    • void setCorePoolSize():设置核心线程数
    • int getPoolSize() 返回池中的当前线程数
    • void setKeepAliveTime(long time, TimeUnit unit) 设置线程在终止前可以保持空闲的时间限制
posted @ 2020-07-11 12:49  tree6x7  阅读(114)  评论(0编辑  收藏  举报