• Java基础(七)——多线程


    一、概述

    1、介绍

      Java VM 启动的时候会有一个进程Java.exe,该进程中至少有一个线程负责Java程序的执行。而且这个线程运行的代码存在于main方法中,该线程称之为主线程。其实从细节上来说,JVM不止启动了一个线程,其实至少有三个线程。除了main() 主线程,还有 gc() 负责垃圾回收机制的线程,异常处理线程。当然如果发生异常,会影响主线程。
      局部的变量在每一个线程区域中都有独立的一份。

    2、程序、进程、线程

      程序(program)是为完成特定任务、用某种语言编写的一组指令的集合 。即一段静态的代码 ,静态对象。
      进程(process)是程序的一次执行过程,或是正在运行的一个程序。是一个动态过程,有它自身的产生、存在和消亡的过程——生命周期。如运行中的QQ、运行中的 MP3播放器。
      线程(thread)是一个程序内部的一条执行路径。由进程可进一步细化为线程。若一个进程同一时间并行执行多个线程,就是支持多线程的。

      理解:
      程序是静态的,进程是动态的。
      进程作为资源分配的基本单位,系统在运行时会为每个进程分配不同的内存区域。每一个进程执行都有一个执行顺序,该顺序是一个执行路径,或者叫一个控制单元。一个进程中至少有一个线程。一个进程当中有可能会存在多条执行路径。
      线程作为调度和执行的基本单位,每个线程拥有独立的运行栈和程序计数器(pc),线程切换的开销小。是进程中一个独立的控制单元,线程在控制着进程的执行。是进程中的内容。
      一个进程中的多个线程共享相同的内存单元/内存地址空间,它们从同一堆中分配对象,可以访问相同的变量和对象。这就使得线程间通信更简便、高效。但多个线程操作共享的系统资源可能就会带来安全隐患。
      形象比喻:一个寝室就是一个进程,能住四个人,就有四个线程,四个人共享阳台和厕所,就是访问相同的变量和对象。而各自的书桌与床是各自私有的。

      为什么有安全隐患?Java内存模型:

      Class Loader:类加载器。
      Execution Engine:执行引擎负责解释命令,提交操作系统执行。
      Native Interface:本地接口。
      Runtime Data Area:运行时数据区。

      进程可以细化为多个线程。每个线程,拥有自己独立的栈和程序计数器;多个线程共享同一个进程中的方法区和堆。也就是说:
      虚拟机栈、程序计数器:一个线程一份,线程私有。
      方法区、堆:一个进程一份,也就是多个线程共享一份。
      结论:多个线程可以共享(一个进程中的)堆(有变量和对象)和方法区,所以实现多个线程间的通信是比较方便的,但是也导致多个线程操作共享资源可能会带来安全隐患。线程同步即用来解决上面的安全隐患。

    3、单核与多核

      单核CPU:其实是一种假的多线程,因为在一个时间单元内,只能执行一个线程的任务。例如:虽然有多车道,但是收费站只有一个工作人员在收费,只有收了费才能通过,那么CPU 就好比收费人员。如果有某个人不想交钱,那么收费人员可以把他"挂起"(晾着他,等他想通了,准备好了钱,再去收费),但是因为CPU时间单元特别短,因此感觉不出来。
      多核CPU:如果是多核的话,才能更好的发挥多线程的效率。现在的服务器都是多核的。

    4、并行与并发

      并行:多个CPU同时执行多个任务。比如:多个人同时做不同的事。
      并发:一个CPU同时执行多个任务(采用时间片原理)。比如:秒杀、多个人做同一件事。

    5、多线程优点

      提高应用程序的响应。对图形化界面更有意义,可增强用户体验;提高计算机系统CPU的利用率;改善程序结构,将既长又复杂的进程分为多个线程,独立运行,利于理解和修改。

    二、线程的创建和使用

    1、介绍

      Thread 类的特性:
      每个线程都是通过某个特定 Thread 对象的 run() 方法来完成操作的,经常把 run() 方法的主体称为线程体。
      通过该 Thread 对象的 start() 方法来启动这个线程,而非直接调用 run()。

      说明:
      run() 方法由 JVM 调用,什么时候调用,执行的过程控制都由操作系统的 CPU 调度决定 。
      想要启动多线程,必须调用 start() 方法 。
      一个线程对象只能调用一次 start() 方法启动,如果重复调用了,则将抛出异常"IllegalThreadStateException"。

    2、创建的四种方式

      JDK 1.5 之前创建线程有两种方法:继承 Thread 类的方式、实现 Runnable 接口的方式。
      JDK 5.0 之后,新增两种方法:实现 Callable 接口的方式、线程池。
      代码示例:方式一、继承 Thread 类

     1 public class Main {
     2     public static void main(String[] args) {
     3         MyThread myThread = new MyThread();
     4         myThread.start();
     5     }
     6 }
     7 
     8 class MyThread extends Thread {
     9 
    10     @Override
    11     public void run() {
    12         for (int i = 0; i < 100; i++) {
    13             if (i % 2 == 0) {
    14                 System.out.println(Thread.currentThread().getName() + ":" + i);
    15             }
    16         }
    17     }
    18 }
    19 
    20 // 匿名方式
    21 public class Main {
    22     public static void main(String[] args) {
    23 
    24         new Thread() {
    25             @Override
    26             public void run() {
    27                 for (int i = 0; i < 100; i++) {
    28                     if (i % 2 == 0) {
    29                         System.out.println(Thread.currentThread().getName() + ":" + i);
    30                     }
    31                 }
    32             }
    33         }.start();
    34     }
    35 }
    继承Thread类

      代码示例:方式二、实现 Runnable 接口
      调用 Thread 类的 start 方法开启线程,会调用当前线程的 run() 方法。

     1 public class Main {
     2     public static void main(String[] args) {
     3         MyThread myThread = new MyThread();
     4         Thread thread1 = new Thread(myThread);
     5         Thread thread2 = new Thread(myThread);
     6 
     7         // 这里开启了两个线程.
     8         thread1.start();
     9         thread2.start();
    10     }
    11 }
    12 
    13 class MyThread implements Runnable {
    14     
    15     @Override
    16     public void run() {
    17         for (int i = 0; i < 100; i++) {
    18             if (i % 2 == 0) {
    19                 System.out.println(Thread.currentThread().getName() + ":" + i);
    20             }
    21         }
    22     }
    23 }
    24 
    25 // 匿名方式
    26 public class Main {
    27     public static void main(String[] args) {
    28         new Thread(new Runnable() {
    29             public void run() {
    30                 for (int i = 0; i < 100; i++) {
    31                     if (i % 2 == 0) {
    32                         System.out.println(Thread.currentThread().getName() + ":" + i);
    33                     }
    34                 }
    35             }
    36         }).start();
    37     }
    实现Runnable接口

      代码示例:方式三、实现 Callable 接口

     1 public class Main {
     2     public static void main(String[] args) throws Exception {
     3         Number number = new Number();
     4 
     5         FutureTask<Integer> futureTask = new FutureTask<>(number);
     6         // 通过线程启动这个任务
     7         new Thread(futureTask).start();
     8 
     9         final Integer sum = futureTask.get();
    10         System.out.println(sum); // 2550
    11     }
    12 }
    13 
    14 class Number implements Callable<Integer> {
    15 
    16     @Override
    17     public Integer call() throws Exception {
    18         int sum = 0;
    19         // 求100以内的偶数和
    20         for (int i = 0; i <= 100; i++) {
    21             if (i % 2 == 0) {
    22                 sum = sum + i;
    23             }
    24         }
    25         return sum;
    26     }
    27 }
    实现Callable接口

      值得注意:
      将futureTask作为Runnable实现类传递,本质为方式二。而调用 Thread 类的 start 方法开启线程,会调用当前线程的 run() 方法
      当前线程的 run()方法 --> futureTask.run() --> callable.call()。
      实例都是通过构造器初始化的。
      返回值即为 call() 方式的返回值。

    1 // Thread类
    2 @Override
    3 public void run() {
    4     if (target != null) {
    5         target.run(); // futureTask.run()
    6     }
    7 }

      方式四:线程池
      见标签:聊聊并发

    3、三种方式的区别

      ①相比继承,实现Runnable接口方式
      好处:避免了单继承的局限性。通过多个线程可以共享同一个接口实现类的对象,天然就能体现多个线程处理共享数据的情况(有共享变量)。参考卖票案例。
      应用:创建了多个线程,多个线程共享数据,则使用实现接口的方式,数据天然的就是共享的。
    在定义线程时,建议使用实现接口的方式。

    1 public class Thread extends Object implements Runnable

      ②相比Runnable,Callable功能更强大些:
      比run()方法,call()有返回值;call()可以抛出异常。被外面捕获,获取异常信息;支持泛型的返回值;需要借助FutureTask类,比如获取返回结果。

    4、线程有关方法

      void start():启动线程,并执行对象的 run() 方法。
      run():线程在被调度时执行的方法体。
      String getName():返回线程的名称。
      void setName(String name):设置该线程的名称。
      static Thread currentThread():返回当前线程对象 。在 Thread 子类中就是 this ,通常用于主线程和 Runnable 实现类。

      static void yield():线程让步,释放当前CPU的执行权。下一刻可能又立马得到。暂停当前正在执行的线程,把执行机会让给优先级相同或更高的线程。若队列中没有同优先级的线程,忽略此方法。

      join():在线程A中调用线程B的join(),此时线程A就进入阻塞状态,直到线程B完全执行完以后,线程A才结束阻塞状态(相当于插队)。低优先级的线程也可以获得执行。

      static void sleep(long millis): 让当前线程睡眠指定毫秒数,在睡眠期间,当前线程是阻塞状态。不会释放锁。令当前活动线程在指定时间段内放弃对 CPU 控制,使其他线程有机会被执行,时间到后重排队。抛出 InterruptedException 异常。

      stop():强制线程生命期结束,不推荐使用。(已过时)。
      boolean isAlive():返回 boolean,判断线程是否还活着。

      代码示例:join() 使用

     1 public class Main {
     2     public static void main(String[] args) throws InterruptedException {
     3         MyThread myThread = new MyThread();
     4         myThread.setName("线程一");
     5         myThread.start();
     6 
     7         Thread.currentThread().setName("主线程");
     8         for (int i = 0; i < 100; i++) {
     9             if (i % 2 == 0) {
    10                 System.out.println(Thread.currentThread().getName() + ":" + i);
    11             }
    12             // 主线程拿到CPU执行权到i=40时,会进入阻塞,等 线程一 执行完才结束
    13             if (i == 40) {
    14                 myThread.join();
    15             }
    16         }
    17     }
    18 }
    19 
    20 class MyThread extends Thread {
    21 
    22     @Override
    23     public void run() {
    24         for (int i = 0; i < 100; i++) {
    25             if (i % 2 == 0) {
    26                 System.out.println(Thread.currentThread().getName() + ":" + i);
    27             }
    28         }
    29     }
    30 }

    5、线程调度

      调度策略:时间片轮训;抢占式,高优先级的线程抢占CPU。

      Java 的调度方法:同优先级线程组成先进先出队列(先到先服务),使用时间片策略;对高优先级,使用优先调度的抢占式策略。

    6、线程的优先级

      等级:

      MAX_PRIORITY:10
      MIN PRIORITY:1
      NORM_PRIORITY:5

      涉及的方法:

      getPriority():返回线程优先级
      setPriority(int newPriority):设置线程的优先级

      说明:线程创建时继承父线程的优先级;高优先级的线程要抢占低优先级线程cpu的执行权。只是从概率上讲,高优先级的线程高概率被执行,低优先级只是获得调度的概率低,并非一定是在高优先级线程执行完之后才被调用。

    7、线程的分类

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

    8、线程使用-售票

      代码示例:方式一、三个窗口同时售100张票

     1 // 继承Thread类来完成
     2 public class Main {
     3     public static void main(String[] args) {
     4         // new 了三次
     5         Window window1 = new Window();
     6         Window window2 = new Window();
     7         Window window3 = new Window();
     8         
     9         window1.setName("窗口一");
    10         window2.setName("窗口二");
    11        window3.setName("窗口三");
    12 
    13        window1.start();
    14        window2.start();
    15        window3.start();
    16     }
    17 }
    18 
    19 class Window extends Thread {
    20     
    21     private int ticket = 100;
    22 
    23     @Override
    24     public void run() {
    25         while (ticket > 0) {
    26             System.out.println(getName() + ":卖票,票号为:" + ticket);
    27             ticket--;
    28         }
    29     }
    30 }

      由于 new 了三次,结果三个窗口各自出售了100张(共300张)。要想三个窗口共同卖 100 张票,对共享变量的访问。修改如下:

    1 private static int ticket = 100;

      结果:100号(也可能是其他号)票依然有重复,这里就存在线程安全问题。

      代码示例:方式二、三个窗口同时售100张票

     1 // 实现Runnable接口来完成
     2 public class Main {
     3     public static void main(String[] args) {
     4         Window window = new Window();
     5 
     6         // 这里体现了三个线程天然的共享一个对象 window 
     7         Thread window1 = new Thread(window);
     8         Thread window2 = new Thread(window);
     9         Thread window3 = new Thread(window);
    10 
    11         window1.setName("窗口一");
    12         window2.setName("窗口二");
    13         window3.setName("窗口三");
    14 
    15         window1.start();
    16         window2.start();
    17         window3.start();
    18     }
    19 }
    20 
    21 class Window implements Runnable {
    22 
    23     private int ticket = 100;
    24 
    25     public void run() {
    26         while (ticket > 0) {
    27             System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);
    28             ticket--;
    29         }
    30     }
    31 }

      结果:100号(也可能是其他号)票依然有重复,这里就存在线程安全问题。
      上面两种方式,只是实现不同,但都存在线程安全问题。后面会解决。

    三、线程的生命周期

    1、五种状态

      JDK 中用 Thread.State 枚举定义了线程的几种状态。要想实现多线程必须在主线程中创建新的线程对象。Java 语言使用 Thread 类及其子类的对象来表示线程,在它的一个完整的生命周期中通常要经历如下的五种状态:
      新建:当一个 Thread 类或其子类的对象被声明并创建时,新生的线程对象处于新建状态。
      就绪:处于新建状态的线程被 start() 后,将进入线程队列等待 CPU 时间片,此时它已具备了运行的条件,只是没分配到 CPU 资源。
      运行:当就绪的线程被调度并获得 CPU 资源时便进入运行状态,开始执行 run(),run() 方法定义了线程的操作和功能。
      阻塞:在某种特殊情况下,被人为挂起或执行输入输出操作时,让出 CPU 并临时中止自己的执行,进入阻塞状态。
      死亡:线程完成了它的全部工作或线程被提前强制性的中止或出现异常导致结束。

    2、状态转换图

    3、涉及方法

      线程类 Thread 的方法:Thread.yield()、Thread.sleep()
      对象类 Object 的方法:wait()、notify()、notifyAll()
      线程对象的方法:其余都是。

      suspend():挂起。为什么过时?因为可能会导致死锁。已过时
      resume():结束挂起的状态。容易导致死锁。已过时
      stop():线程终止。已过时

      suspend()、resume():容易导致死锁,这两个操作就好比播放器的暂停和恢复。但这两个 API 是过期的,也就是不建议使用的。
      不推荐使用 suspend() 去挂起线程的原因,是因为 suspend() 导致线程暂停的同时,并不会去释放任何锁资源。其他线程都无法访问被它占用的锁。直到对应的线程执行 resume() 方法后,被挂起的线程才能继续,从而其它被阻塞在这个锁的线程才可以继续执行。
      但是,如果 resume() 操作出现在 suspend() 之前执行,那么线程将一直处于挂起状态,同时一直占用锁,这就产生了死锁。而且,对于被挂起的线程,它的线程状态居然还是 Runnable。

    四、线程的同步

    1、售票的问题

      上述售票案例中,不管是方式一还是方式二,都存在重票的情况,这里让线程睡眠0.1s,暴露出错票的情况。这就是线程安全问题。
      代码示例:有线程安全问题的售票

     1 // 方式一、继承Thread类来完成
     2 class Window extends Thread {
     3 
     4     private static int ticket = 100;
     5 
     6     public void run() {
     7         while (ticket > 0) {
     8 
     9             try {
    10                 // 增大错票的概率
    11                 Thread.sleep(100);
    12             } catch (InterruptedException e) {
    13                 e.printStackTrace();
    14             }
    15 
    16             System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);
    17             ticket--;
    18         }
    19     }
    20 }
    21 
    22 // 方式二、实现Runnable接口来完成
    23 class Window implements Runnable {
    24 
    25     private int ticket = 100;
    26 
    27     public void run() {
    28         while (ticket > 0) {
    29 
    30             try {
    31                 // 增大错票的概率
    32                 Thread.sleep(100);
    33             } catch (InterruptedException e) {
    34                 e.printStackTrace();
    35             }
    36 
    37             System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);
    38             ticket--;
    39         }
    40     }
    41 }
    42 
    43 // 可能的结果.这里只放最后 10 行打印
    44 窗口二:卖票,票号为:9
    45 窗口三:卖票,票号为:9
    46 窗口一:卖票,票号为:7
    47 窗口二:卖票,票号为:6
    48 窗口三:卖票,票号为:6
    49 窗口一:卖票,票号为:4
    50 窗口二:卖票,票号为:3
    51 窗口三:卖票,票号为:2
    52 窗口一:卖票,票号为:1
    53 窗口二:卖票,票号为:0
    54 窗口三:卖票,票号为:-1

      问题:很明显,上述售票有重票(9),还是错票(-1),那么如何解决这种线程安全问题呢?
      注意:这里并不是加了sleep之后,才出现重票错票的情况。sleep只是将这种情况出现的概率提高了。
      解决:解决线程安全问题有两种方式
      ①synchronize(隐式锁):同步代码块、同步方法
      ②Lock(显式锁)

    2、同步代码块(synchronize)

    1 synchronized (同步监视器) {
    2     // 需要被同步的代码
    3 }

      ①什么是需要被同步的代码?
      有没有共享数据:多个线程共同操作的变量。案例中的 ticket。
      有操作共享数据的代码,即为需要被同步的代码。
      同步监视器,俗称:锁。任何一个类的对象,都可以充当锁。要求:多个线程必须共用同一把锁。
      ②优缺点?
      优点:同步的方式,解决了线程安全的问题。
      缺点:对同步代码的操作,只能有一个线程参与,其他线程必须等待。相当于是一个单线程的过程,效率低。

      代码示例:处理"实现Runnable的线程安全问题"
      注意:以下的 object 可以换成 this。

     1 class Window implements Runnable {
     2 
     3     private int ticket = 100;
     4     private Object object = new Object();
     5 
     6     public void run() {
     7         while (true) {
     8             synchronized (object) { // this 唯一的 Window 对象
     9                 if (ticket > 0) {
    10                     try {
    11                         // 增大错票的概率
    12                         Thread.sleep(100);
    13                     } catch (InterruptedException e) {
    14                         e.printStackTrace();
    15                     }
    16 
    17                     System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);
    18                     ticket--;
    19                 } else {
    20                     break;
    21                 }
    22             }
    23         }
    24     }
    25 }

      代码示例:处理"继承Thread类的线程安全问题"
      仿造前一个的方案:传入一个object,是行不通的。原因:不是同一把锁。有三个object。写 this 也是不对的,正确写法:
      以下的 object 可以换成 Window.class。类:也是对象。Window.class 只会加载一次。

     1 class Window extends Thread {
     2 
     3     private static int ticket = 100;
     4     private static Object object = new Object();
     5 
     6     public void run() {
     7         while (true) {
     8             synchronized (object) { // Window.class
     9                 if (ticket > 0) {
    10                     try {
    11                         // 增大错票的概率
    12                         Thread.sleep(100);
    13                     } catch (InterruptedException e) {
    14                         e.printStackTrace();
    15                     }
    16 
    17                     System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);
    18                     ticket--;
    19                 } else {
    20                     break;
    21                 }
    22             }
    23         }
    24     }
    25 }

      深刻理解:synchronized包含的代码块一定不能包含了while。这样会导致,第一个拿到锁的线程把票卖光之后,才释放锁,这不符合题意。

    3、同步方法(synchronize)

      如果操作共享数据的代码完整的声明在一个方法中,那么不妨将此方法声明为同步的。
      代码示例:处理"实现Runnable的线程安全问题"

     1 class Window implements Runnable {
     2 
     3     private int ticket = 100;
     4 
     5     public void run() {
     6         while (true) {
     7             show();
     8         }
     9     }
    10 
    11    // 这里使用了默认的锁:this
    12    private synchronized void show() {
    13         if (ticket > 0) {
    14             try {
    15                 // 增大错票的概率
    16                 Thread.sleep(100);
    17             } catch (InterruptedException e) {
    18                 e.printStackTrace();
    19             }
    20 
    21             System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);
    22             ticket--;
    23         }
    24     }
    25 }

      代码示例:处理"继承Thread类的线程安全问题"

     1 class Window extends Thread {
     2 
     3     private static int ticket = 100;
     4 
     5     public void run() {
     6         while (true) {
     7             show();
     8         }
     9     }
    10     
    11    // 这里使用 Window.class
    12    private static synchronized void show() {
    13         if (ticket > 0) {
    14             try {
    15                 // 增大错票的概率
    16                 Thread.sleep(100);
    17             } catch (InterruptedException e) {
    18                 e.printStackTrace();
    19             }
    20 
    21             System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);
    22             ticket--;
    23         }
    24     }
    25 }

      总结:同步方法仍然涉及到锁,只是不需要我们显式声明;非静态的同步方法,锁是this,静态的同步方法,锁是类对象。

    5、死锁的问题

      死锁:不同的线程分别占用对方需要的同步资源不放弃,都在等待对方释放自己需要的同步资源,就形成了线程的死锁。
      出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续。我们同步时,应尽量避免出现死锁。
      解决方法:专门的算法、原则。尽量减少同步资源的定义。尽量避免嵌套同步。
      代码示例:死锁

     1 public class Main {
     2     public static void main(String[] args) {
     3         final StringBuffer buffer1 = new StringBuffer();
     4         final StringBuffer buffer2 = new StringBuffer();
     5 
     6         // 继承的匿名方式
     7         new Thread() {
     8             @Override
     9             public void run() {
    10                 synchronized (buffer1) {
    11                     buffer1.append("a");
    12                     buffer2.append("1");
    13                     // 可以暴露出死锁的问题
    14 //                    try {
    15 //                        Thread.sleep(200);
    16 //                    } catch (InterruptedException e) {
    17 //                        e.printStackTrace();
    18 //                    }
    19 
    20                     synchronized (buffer2) {
    21                         buffer1.append("b");
    22                         buffer2.append("2");
    23                         System.out.println(getName() + ":" + buffer1 + "-" + buffer2);
    24                     }
    25                 }
    26             }
    27         }.start();
    28 
    29         // 实现的匿名方式
    30         new Thread(new Runnable() {
    31             @Override
    32             public void run() {
    33                 synchronized (buffer2) {
    34                     buffer1.append("c");
    35                     buffer2.append("3");
    36 
    37                     synchronized (buffer1) {
    38                         buffer1.append("d");
    39                         buffer2.append("4");
    40 
    41                         System.out.println(Thread.currentThread().getName() + ":" + buffer1 + "-" + buffer2);
    42                     }
    43                 }
    44             }
    45         }).start();
    46     }
    47 }
    48 
    49 // 可能的结果
    50 Thread-0:ab-12
    51 Thread-1:abcd-1234
    52 
    53 // 还可能是别的结果

      让线程一在获取到buffer1的时候,睡眠0.1s。线程二获取到buffer2的时候,睡眠0.1s,可以让死锁的问题暴露出来。

    6、Lock(接口)

      从JDK 5.0 开始,Java提供了更强大的线程同步机制——通过显示定义同步锁对象来实现同步。同步锁使用Lock对象充当。
      java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。
      ReentrantLock类(可重入锁)实现了Lock,它拥有与synchronize相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显示加锁、释放锁。
      代码示例:用 Lock 解决卖票的同步问题。

     1 class Window implements Runnable {
     2 
     3     private int ticket = 100;
     4     private Lock lock = new ReentrantLock(true);
     5 
     6     public void run() {
     7         while (true) {
     8             try {
     9                 // 获取锁
    10                 lock.lock();
    11                 if (ticket > 0) {
    12                     try {
    13                         Thread.sleep(100);
    14                     } catch (InterruptedException e) {
    15                         e.printStackTrace();
    16                     }
    17 
    18                     System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);
    19                     ticket--;
    20                 } else {
    21                     break;
    22                 }
    23             } finally {
    24                 // 释放锁
    25                 lock.unlock();
    26             }
    27         }
    28 
    29         System.out.println(Thread.currentThread().getName() + "A");
    30     }
    31 }
    32 
    33 // 结果.这里只截取了最后几行
    34 …………
    35 窗口一:卖票,票号为:4
    36 窗口二:卖票,票号为:3
    37 窗口三:卖票,票号为:2
    38 窗口一:卖票,票号为:1
    39 窗口二A
    40 窗口三A
    41 窗口一A

      说明:即使遇到break,finally里的代码块也会被执行。
      ReentrantLock(boolean fair):含参构造器,fair:true,公平锁、线程先进先出。保证当线程一、线程二、线程三来了之后,线程一执行完之后,线程二拿到锁,而不是线程一又拿到锁。false:非公平锁、多个线程抢占式。
      ReentrantLock():无参构造器,fair为false。则不难理解为什么是上述结果了。
      值得注意的是:try{}并非为了捕获异常,此次代码也没有catch块,是为了执行finally块将锁释放出来。否则会导致死循环(卖票情况正常,且没有同步问题)。
      不写try,finally的结果:死循环

      说明:窗口一卖出最后一张票,且释放了锁。窗口二获取到锁以后,此时ticket = 0,则执行break,窗口二跳出while循环,但此时并没有释放锁。而另外两个线程一直在等待获取锁,导致了死循环。

    7、synchronize与Lock的异同

      相同:都用于解决线程安全问题
      不同:synchronize机制在执行完相应的同步代码之后,会自动的释放锁。而Lock需要手动获取锁,同时需要在finally中手动释放锁。
      Lock是一个接口,而synchronized是关键字。
      Lock是显示锁(必须手动开启与释放),synchronize是隐式锁,出了作用域自动释放。
      Lock可以让等待锁的线程响应中断,而synchronize不会,线程会一直等下去。
      Lock可以知道线程有没有拿到锁,而synchronize不能。
      Lock可以实现多个线程同时读操作,而synchronize不能,线程必须等待。
      Lock只有代码块锁,synchronize有代码块锁和方法锁。
      使用Lock锁,JVM将花费较少的时间来调度线程,性能更好,并且具有更好的扩展性(提供更多的子类)。
      Lock有比synchronize更精确的线程语义和更好的性能,Lock还有更强大的功能,例如,它的tryLock方法可以非阻塞的方式去拿锁。

      优先使用顺序:
      Lock-->同步代码块(进入了方法体,分配了相应资源)-->同步方法(在方法体外)

      代码示例:银行有一个账户,两个储户分别向同一个账户存3000元,每次存1000,存3次。每次存完打印账户余额。

     1 public class AccountTest {
     2     public static void main(String[] args) {
     3         Account account = new Account(0);
     4 
     5         Customer customer1 = new Customer(account);
     6         Customer customer2 = new Customer(account);
     7 
     8         customer1.setName("甲");
     9         customer2.setName("乙");
    10 
    11         customer1.start();
    12         customer2.start();
    13     }
    14 }
    15 
    16 class Customer extends Thread {
    17     private Account account;
    18 
    19     public Customer(Account account) {
    20         this.account = account;
    21     }
    22 
    23     @Override
    24     public void run() {
    25         for (int i = 0; i < 3; i++) {
    26             account.deposit(1000);
    27         }
    28     }
    29 }
    30 
    31 class Account {
    32     private double balance;
    33 
    34     public Account(double bal) {
    35         this.balance = bal;
    36     }
    37 
    38     public synchronized void deposit(double amt) {
    39         if (amt > 0) {
    40             balance += amt;
    41             System.out.println(Thread.currentThread().getName() + "存钱后余额为:" + balance);
    42         }
    43     }
    44 }
    45 
    46 // 可能的一种结果
    47 甲存钱后余额为:1000.0
    48 甲存钱后余额为:2000.0
    49 乙存钱后余额为:3000.0
    50 乙存钱后余额为:4000.0
    51 乙存钱后余额为:5000.0
    52 甲存钱后余额为:6000.0

    五、线程的通信

    1、wait、notify、notifyAll

      问:wait 等待中的线程被 notify 唤醒了会立马执行吗?
      答:不会。被唤醒的线程需要重新竞争锁对象,获得锁的线程可以从wait处继续往下执行。

      代码示例:使用两个线程交替打印1—100。

     1 // 错误示例,结果:交错式打印
     2 class Number1 implements Runnable {
     3 
     4     private int number = 1;
     5 
     6     public void run() {
     7         while (true) {
     8             synchronized (this) {
     9                 if (number <= 100) {
    10                     System.out.println(Thread.currentThread().getName() + ":" + number);
    11                     number++;
    12                 } else {
    13                     break;
    14                 }
    15             }
    16         }
    17     }
    18 }
    错误示例

      这里需要用到线程的通信,正确方式如下:

     1 /**
     2  * 分析:很自然的想到,线程一打印了1之后,需要让一阻塞,然后让线程二打印2
     3  * 然后将一唤醒,二再阻塞,依次内推。
     4  */
     5 // 正确示例,结果:交替打印
     6 public class Main {
     7     public static void main(String[] args) {
     8         Number num = new Number();
     9         Thread thread1 = new Thread(num);
    10         Thread thread2 = new Thread(num);
    11 
    12         thread1.start();
    13         thread2.start();
    14     }
    15 }
    16 
    17 class Number implements Runnable {
    18 
    19     private int number = 1;
    20 
    21     @Override
    22     public void run() {
    23         while (true) {
    24             // this : num
    25             synchronized (this) {
    26                 // 唤醒一个线程
    27                 notify();
    28                 if (number <= 100) {
    29                     System.out.println(Thread.currentThread().getName() + ":" + number);
    30                     number++;
    31                     try {
    32                         // 使得调用该方法的线程进入阻塞状态
    33                         wait();
    34                     } catch (InterruptedException e) {
    35                         e.printStackTrace();
    36                     }
    37                 } else {
    38                     break;
    39                 }
    40             }
    41         }
    42     }
    43 }
    44 
    45 // 结果
    46 实现交替打印
    交替打印

      注意:上面两个线程公用同一把锁 num,this 指num。
      若此时将同步监视器换成

    1 private final Object object = new Object();
    2 synchronized (object) {}

      会报异常"IllegalMonitorStateException"。同步监视器非法

      原因:默认情况下,方法前有一个this,而锁却是Object。

    1 this.notify();
    2 this.wait();

      如果要用Object当锁,需要修改为:

    1 object.notify();
    2 object.wait();

      理解:其实不难理解,解决线程同步问题,是要求要同一把锁。锁是object,而唤醒却是this,就不知道要唤醒谁了呀?应该唤醒跟我(当前线程)共用同一把锁的线程,唤醒别人(别的锁)有什么意义呢?而且本身还是错的。
      形象理解:一个寝室四个人(四个线程)有一个厕所(共享资源),共用厕所(多个线程对共享资源进行访问)有安全隐患,如何解决?加锁。当甲进入厕所时,将厕所门前挂牌(锁)拿走(获得锁),然后使用厕所(此时其他人都进不来,必须在门外等待获得挂牌,即时此时甲在厕所睡着了sleep(),其他人依然要等待,因为甲依然拿着挂牌,线程没有释放锁),使用完毕后,将挂牌挂于门前(线程释放锁),其他三人方可使用,使用前先竞争锁。
    若要求两人交替使用厕所,那么当甲使用完毕,通知(notify)乙使用,甲去等待(wait)下一次使用,自然而然,甲需要释放锁。就是甲使用时,乙等待,甲用完了,通知乙(我用完了,你去吧),乙使用时,甲等待,乙用完了,通知甲(我用完了,你去吧)。那么很自然的问题是,甲用完后,通知谁?是通知和我竞争同一个厕所的人,不会去通知隔壁寝室的人(即我用完了,释放出锁,通知竞争这把锁的线程)。
      总结:
      wait():一旦执行此方法,当前线程进入阻塞状态,并释放锁。
      notify():一旦执行此方法,就会唤醒一个被wait()的线程。如果有多个,就唤醒优先级高的,如果优先级一样,则随机唤醒一个。
      notifyAll():一旦执行此方法,会唤醒所有wait()的线程。
      以上三个方法必须使用在同步代码块或同步方法中,这里才有锁。如果是Lock,有别的方式(暂未介绍,可自行百度)。
      以上三个方法的调用者必须是同步代码块或同步方法中的同步监视器。否则会出现异常。
      而任何一个类的对象,都可以充当锁。则当锁是object时,根据上一条,以上三个方法调用者就是object,所以定义在java.lang.Object类中。

    2、sleep与wait的异同

      相同:都可以使当前线程进入阻塞状态。
      不同:声明位置不同,Thread类中声明sleep(),Object类中声明wait();sleep()随时都可以调用,wait()必须在同步代码块或同步方法中;sleep()不会释放锁,wait()会释放锁;sleep(),超时或者调用interrupt()方法就可以唤醒,wait(),等待其他线程调用对象的notify()或者notifyAll()方法才可以唤醒。

    3、生产者与消费者

      见标签:聊聊并发

    作者:Craftsman-L

    本博客所有文章仅用于学习、研究和交流目的,版权归作者所有,欢迎非商业性质转载。

    如果本篇博客给您带来帮助,请作者喝杯咖啡吧!点击下面打赏,您的支持是我最大的动力!

  • 相关阅读:
    ThreadLocal用法
    Spring Cloud Alibaba 使用RestTemplate进行服务消费
    Spring Cloud Alibaba 使用Nacos作为配置管理中心
    Spring Cloud Alibaba 使用Nacos作为服务注册中心
    Spring Cloud Alibaba 介绍及工程准备
    Redission 支持GsonCodec
    Maven Archetype快速构建项目
    拜占庭将军问题
    Paxos算法详解
    Paxos、Raft分布式一致性算法应用场景(转载)
  • 原文地址:https://www.cnblogs.com/originator/p/15351550.html
Copyright © 2020-2023  润新知