Java多线程02(线程安全、线程同步、等待唤醒机制)

时间:2024-04-09 19:34:09

Java多线程2(线程安全、线程同步、等待唤醒机制、单例设计模式)

1、线程安全

  • 如果有多个线程在同时运行,而这些线程可能会同时运行这段代码。程序每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。

    • 通过案例演示线程的安全问题:电影院要卖票。
    • 我们模拟电影院的卖票过程。假设本场电影的座位共100个(本场电影只能卖100张票)。
    • 我们来模拟电影院的售票窗口,实现多个窗口同时卖这场电影的票(多个窗口一起卖这100张票)
    • 需要窗口,采用线程对象来模拟;
    • 需要票,Runnable接口子类来模拟;
  • 代码:

public class Tickets implements Runnable {
private int num = 100; //(1)
@Override
public void run() { // (2)
// 死循环,一直处于可以售票状态
while(true) { // (3)
if(num>0) { // (4)
System.out.println(Thread.currentThread().getName()+" 第 "+ num-- + " 张票售出"); //(5)
}
}
}
} public class TicketsDemo {
public static void main(String[] args) { //(6)
Tickets t = new Tickets(); // (7)
new Thread(t).start(); // (8)
new Thread(t).start(); // (9)
new Thread(t).start(); // (10)
}
}
  • 分析:

    • 三个窗口每个窗口都在买票,假设此时只剩一张票,可能会发生以下情况:
    • 线程t1执行run方法到(4)时,产生阻塞,线程t2执行run方法到(4)时叶阻塞,线程t3执行完了run方法,释放CPU,此时num=0;t1再次得到CPU时,不会再次判断,而是直接执行下一步(5),这时就会发生0--,出现出售第0张票,并且票数变成负数,这样就出现了安全隐患。
  • 运行结果发现:上面程序出现了问题

    • 票出现了重复的票
    • 错误的票 0、-1
  • 线程安全问题都是由全局变量及静态变量引起的。

  • 若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。

  • 解决办法:

    • 当一个线程进入数据操作的时候,无论是否休眠,其他线程智能等待。

2、线程同步(线程安全处理Synchronized)

  • java中提供了线程同步机制,它能够解决上述的线程安全问题。
  • 线程同步的方式有两种:
    • 方式1:同步代码块
    • 方式2:同步方法

2.1 同步代码块

  • 同步代码块: 在代码块声明上 加上synchronized
synchronized (锁对象) {
可能会产生线程安全问题的代码
}
  • 同步代码块中的锁对象可以是任意的对象;但多个线程时,要使用同一个锁对象才能够保证线程安全。

  • 使用同步代码块,对电影院卖票案例中Ticket类进行如下代码修改:

/*
通过线程休眠,出现安全问题
解决安全问题,Java程序,提供同步技术
公式:
syncronized (任意对象){
线程要操作的共享数据
}
*/
public class Tickets implements Runnable {
// 定义出售的票数
private int num = 100;
Object obj = new Object(); // 创建对象,用于同步
@Override
public void run() {
// 死循环,一直处于可以售票状态
while(true) {
// 线程共享数据,保证安全,加入同步代码块
synchronized (obj) {
if(num>0) {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" 第 "+ num-- + " 张票售出");
}
}
}
}
}
  • 当使用了同步代码块后,上述的线程的安全问题,解决了。
  • 分析:
    • 同步对象:可以是任意对象,可以称之为同步锁,对象监视器,注意不能用匿名内部类,因为这样在会导致每次获得锁对象都是新的对象,无法实现加锁的效果。
    • 同步是如何保证安全性的:没有锁的线程不能执行,只能等待。
    • 具体执行过程:
      • 线程遇到同步代码块后,线程判断同步锁还有没有
      • 如果同步锁有:获取锁,进入同步中,去执行,执行完毕后,离开同步代码块,线程将锁对象还回去。
      • 在同步中的线程休眠,此时另一个线程会执行;
      • 遇到同步代码块,判断对象锁是否还有,如果没有锁,该线程不能进入同步代码块中执行,被阻挡在同步代码块的外面,处于阻塞状态。
    • 加了同步之后,执行步骤增加:线程首先进同步判断锁,获取锁,出同步释放锁,导致程序运行速度的下降。
    • 没有锁的线程,不能进入同步,在同步中的线程,不出同步,不会释放锁。

2.2 同步方法(推荐使用)

  • 同步方法:在方法声明上加上synchronized
public synchronized void method(){
可能会产生线程安全问题的代码
}
  • 同步方法中的锁对象是 this
  • 使用同步方法,对电影院卖票案例中Ticket类进行如下代码修改:
/*
采用同步方法的形式解决线程安全问题
好处:代码量少,简洁
做法:将线程共享数据和同步抽取到方法中
*/
public class Tickets implements Runnable {
private int num = 100;
@Override
public void run() {
// 死循环,一直处于可以售票状态
while(true) {
payTicket();
}
} public synchronized void payTicket() {
if(num>0) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" 第 "+ num-- + " 张票售出");
}
}
}
  • 问题:同步方法中有锁吗?

    • 有,同步方法中的对象锁是本类方法的引用
  • 静态同步方法: 在方法声明上加上static synchronized

public static synchronized void method(){
// 可能会产生线程安全问题的代码
}
  • 静态同步方法中的锁对象是本类自己:类名.class

1.4 Lock接口

  • 查阅API,查阅Lock接口描述,Lock 实现提供了比使用 synchronized 方法和语句可获得的更广泛的锁定操作。
    • 实现类:ReentrantLock
    • Lock接口中的常用方法
      • void lock():获得锁。
      • void unlock():释放锁。
    • Lock提供了一个更加面对对象的锁,在该锁中提供了更多的操作锁的功能。
  • 我们使用Lock接口,以及其中的lock()方法和unlock()方法替代同步,对电影院卖票案例中Ticket类进行如下代码修改:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock; /*
使用JDK1.5+的接口Locl,替换同步代码块,实现线程安全
具体使用:
Lock接口中的方法:
lock(); // 获取锁
unlock(); // 释放锁
实现类:ReentrantLock
*/
public class Tickets implements Runnable {
// 存储票数
private static int num = 100;
//在类的成员位置,创建Lock接口的实现类对象
private Lock lock = new ReentrantLock();
@Override
public void run() {
// 死循环,一直处于可以售票状态
while(true) {
// 调用Lock接口中的方法,获取锁
lock.lock();
try {
if(num>0) {
Thread.sleep(200);
System.out.println(Thread.currentThread().getName()+" 第 "+ num-- + " 张票售出");
}
}catch (InterruptedException e) {
e.printStackTrace();
}finally {
// 释放锁,调用unlock方法
lock.unlock();
}
}
}
}

1.4 死锁

  • 同步锁使用的弊端:当线程任务中出现了多个同步(多个锁)时,如果同步中嵌套了其他的同步。这时容易引发一种现象:

    • 程序出现无限等待,这种现象我们称为死锁。这种情况能避免就避免掉。
  • 死锁程序:

    • 前提:必须是多线程
    • 出现同步嵌套
    • 线程进入同步,获取锁,不出去同步,不会释放锁
  • 锁的嵌套情况如下:

    synchronzied(A锁){
    synchronized(B锁){ }
    }
    synchronzied(B锁){
    synchronized(A锁){ }
    }
  • 注意A锁和B锁都是唯一的

  • 两个线程每个获得一个锁,且都需要对方的锁才能继续执行,因此都会一直除以阻塞状态,无法恢复,出现死锁。

  • 我们进行下死锁情况的代码演示:

    // 定义锁对象类
    /*
    不允许任何类创建该对象
    只能通过类名调用静态成员调用,不允许new
    保证了锁的唯一性
    */
    public class LockA {
    private LockA() {}
    public final static LockA locka = new LockA();
    } public class LockB {
    private LockB() {}
    public final static LockB lockb = new LockB();
    } // 线程任务类
    public class DeadLock implements Runnable{
    private int i = 0;
    @Override
    public void run() {
    while(true) {
    if(i%2==0) {
    // 先进入A同步,再进入B同步
    synchronized (LockA.locka) {
    System.out.println(i+" --> if...locka");
    synchronized (LockB.lockb) {
    System.out.println(i+" --> if...lockb");
    }
    }
    }else {
    // 先进入B同步,再进入B同步
    synchronized (LockB.lockb) {
    System.out.println(i+" --> else...lockb");
    synchronized (LockA.locka) {
    System.out.println(i+" --> else...locka");
    }
    }
    }
    i++;
    }
    }
    } // 测试类
    public class DeadLockDemo {
    public static void main(String[] args) {
    DeadLock deadLock = new DeadLock();
    new Thread(deadLock).start();
    new Thread(deadLock).start();
    }
    } // 运行结果:
    0 --> if...locka
    0 --> if...lockb
    1 --> else...lockb
    1 --> if...locka

1.5 等待唤醒机制

  • 在开始讲解等待唤醒机制之前,有必要搞清一个概念—— 线程之间的通信。

  • 线程之间的通信:多个线程在处理同一个资源,但是处理的动作(线程的任务)却不相同。通过一定的手段使各个线程能有效的利用资源。而这种手段即—— 等待唤醒机制。

  • 等待唤醒机制所涉及到的方法:

    • wait() :等待,无限等待。将正在执行的线程释放其执行资格 和 执行权,并存储到线程池中。
    • notify() :唤醒。唤醒线程池中被wait()的线程,一次唤醒一个,而且是任意的。
    • notifyAll() :唤醒全部:可以将线程池中的所有wait()线程都唤醒。
  • 所谓唤醒:就是让线程池中的线程具备执行资格。

    • 必须注意的是:这些方法都是在同步中才有效。同时这些方法在使用时必须标明所属锁,这样才可以明确出这些方法操作的到底是哪个锁上的线程。
  • 仔细查看Java API之后,发现这些方法 并不定义在 Thread中,也没定义在Runnable接口中,却被定义在了Object类中,为什么这些操作线程的方法定义在Object类中?

    • 因为这些方法在使用时,必须要标明所属的锁,而锁又可以是任意对象。能被任意对象调用的方法一定定义在Object类中。
  • 线程通讯案例:输入线程向Resource中输入name ,sex , 输出线程从资源中输出,先要完成的任务是:

    1. 当input发现Resource中没有数据时,开始输入,输入完成后,叫output来输出。如果发现有数据,就wait();
    2. 当output发现Resource中没有数据时,就wait() ;当发现有数据时,就输出,然后,叫醒input来输入数据。
  • 下面代码,模拟等待唤醒机制的实现:

    • Resource.java

      /*
      定义资源类,有2个成员变量:
      name,sex
      同时有两个线程,对资源中的变量操作
      1个对name,sex赋值
      1个对name,sex做变量的输出打印
      */
      public class Resource {
      public String name;
      public String sex;
      }
    • Input.java

      /*
      输入线程:
      对资源对象Resource中的成员变量赋值
      要求:
      一次赋值:张三,男
      另一次:李四,女 */
      public class Input implements Runnable {
      private Resource r;
      public Input(Resource r) {
      this.r = r;
      }
      @Override
      public void run() {
      int i = 0;
      while(true) {
      if(i%2==0) {
      r.name = "张三";
      r.sex = "男";
      }else {
      r.name = "lisi";
      r.sex = "nv";
      }
      i++;
      }
      }
      }
    • Output.java

      /*
      输出线程:对资源对象Resource中的成员变量输出值
      */
      public class Output implements Runnable {
      private Resource r;
      public Output(Resource r) {
      this.r = r;
      }
      @Override
      public void run() {
      while(true) {
      System.out.println("姓名:"+r.name + ", 性别:"+r.sex);
      }
      } }
    • ThreadDemo.java

      /*
      开启输入线程和输出线程,实现赋值和打印
      */
      public class ThreadDemo {
      public static void main(String[] args) {
      Resource r = new Resource(); //共享数据
      Input in = new Input(r);
      Output out = new Output(r);
      new Thread(in).start();
      new Thread(out).start();
      }
      }
  • 此时会出现问题:打印出的结果并不是想要的结果

    姓名:lisi, 性别:nv
    姓名:张三, 性别:nv
    姓名:lisi, 性别:男
    姓名:lisi, 性别:nv
    姓名:lisi, 性别:nv
    姓名:张三, 性别:男
  • 分析原因,两个线程没有实现同步。

  • 实现同步的方法:给线程加同步锁。

    • 注意:给输入和输出加的同步锁应为同一个对象锁,而输入和输出线程是两个不同的线程,因此不能使用this作为对象锁,这里使用他们公用的资源类Resource对象。
  • 代码修改如下:

    • Input.java修改

      /*
      输入线程:
      对资源对象Resource中的成员变量赋值
      要求:
      一次赋值:张三,男
      另一次:李四,女 */
      public class Input implements Runnable {
      private Resource r;
      public Input(Resource r) {
      this.r = r;
      }
      @Override
      public void run() {
      int i = 0;
      while(true) {
      synchronized (r) {
      if(i%2==0) {
      r.name = "张三";
      r.sex = "男";
      }else {
      r.name = "lisi";
      r.sex = "nv";
      }
      }
      i++;
      }
      }
      }
    • Input.java修改

      /*
      输入线程:
      对资源对象Resource中的成员变量赋值
      要求:
      一次赋值:张三,男
      另一次:李四,女 */
      public class Input implements Runnable {
      private Resource r;
      public Input(Resource r) {
      this.r = r;
      }
      @Override
      public void run() {
      int i = 0;
      while(true) {
      synchronized (r) {
      if(i%2==0) {
      r.name = "张三";
      r.sex = "男";
      }else {
      r.name = "lisi";
      r.sex = "nv";
      }
      }
      i++;
      }
      }
      }
    • Output.java修改:

      /*
      输出线程:对资源对象Resource中的成员变量输出值
      */
      public class Output implements Runnable {
      private Resource r;
      public Output(Resource r) {
      this.r = r;
      }
      @Override
      public void run() {
      while(true) {
      synchronized (r) {
      System.out.println("姓名:"+r.name + ", 性别:"+r.sex);
      }
      }
      }
      }
  • 此时还有问题:输出没有交替进行

    姓名:张三, 性别:男
    姓名:张三, 性别:男
    姓名:张三, 性别:男
    姓名:lisi, 性别:nv
    姓名:lisi, 性别:nv
    姓名:lisi, 性别:nv
    姓名:lisi, 性别:nv
  • 分析原因:

    • 输入:输入完成以后,必须等待,等待输出打印结束后,才能进行下一次赋值。
    • 输出:输出完变量值后,必须等待,等待输入的重新赋值后,才能进行下一次打印。
  • 解决方法:

    • 输入:赋值后,执行方法wait(),永远等待,
    • 输出:变量打印输出,在输出等待之前,唤醒输入的nitify(),自己再wait等待。
    • 输入:被唤醒后,重新对变量赋值,然后唤醒输出的线程notify,自己再wait()等待。
    • 如何判断输入输出结束:设置一个标记flag,以标记为准;
      • flag = false; 说明赋值完成
      • flag = true; 获取值完成
    • 输入操作:
      • 需要不需要赋值,看标记
      • 如果标记为true,等待
      • 如果标记为false,不需要等待,赋值
      • 赋值后,将标记改为true
    • 输出操作:
      • 需要不需要获取,看标记
      • 如过标记为false,等待
      • 如果标记为true,打印
      • 打印后,将标记改为false
  • 代码修改如下:

    • Resource.java修改

      /*
      定义资源类,有2个成员变量:
      name,sex
      同时有两个线程,对资源中的变量操作
      1个对name,sex赋值
      1个对name,sex做变量的输出打印
      */
      public class Resource {
      public String name;
      public String sex;
      public boolean flag = false;
      }
    • Input.java修改

      /*
      输入线程:
      对资源对象Resource中的成员变量赋值
      要求:
      一次赋值:张三,男
      另一次:李四,女 */
      public class Input implements Runnable {
      private Resource r;
      public Input(Resource r) {
      this.r = r;
      }
      @Override
      public void run() {
      int i = 0;
      while(true) {
      synchronized (r) {
      if(r.flag) { // 标记是true,等待
      try {
      r.wait();
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      }
      if(i%2==0) {
      r.name = "张三";
      r.sex = "男";
      }else {
      r.name = "lisi";
      r.sex = "nv";
      }
      // 标记改为true,将对方线程唤醒
      r.flag = true;
      r.notify();
      }
      i++;
      }
      }
      }
    • Output.java修改

      /*
      输出线程:对资源对象Resource中的成员变量输出值
      */
      public class Output implements Runnable {
      private Resource r;
      public Output(Resource r) {
      this.r = r;
      }
      @Override
      public void run() {
      while(true) {
      synchronized (r) {
      if(!r.flag) { // 判断标记,false,等待
      try {
      r.wait();
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      }
      System.out.println("姓名:"+r.name + ", 性别:"+r.sex);
      r.flag = false;
      r.notify();
      }
      }
      }
      }
  • 注意:

    • 等待和唤醒必须是由同一个对象调用,这里用Resource的对象

2 总结

同步锁

  • 多个线程想保证线程安全,必须要使用同一个锁对象

    • 同步代码块

      synchronized (锁对象){
      可能产生线程安全问题的代码
      }
  • 同步代码块的锁对象可以是任意的对象

    • 同步方法

      public synchronized void method()
      可能产生线程安全问题的代码
      }
      // 同步方法中的锁对象是 this
    • 静态同步方法

      public synchronized void method()
      可能产生线程安全问题的代码
      }
      // 静态同步方法中的锁对象是 类名.class

多线程有几种实现方案,分别是哪几种?

  • 继承Thread类
  • 实现Runnable接口
  • 通过线程池,实现Callable接口

同步有几种方式,分别是什么?

  • 同步代码块
  • 同步方法
  • 静态同步方法

启动一个线程是run()还是start()?它们的区别?

  • 启动一个线程是start()
  • 区别:
    • start: 启动线程,并调用线程中的run()方法
    • run : 执行该线程对象要执行的任务

sleep()和wait()方法的区别

  • sleep: 不释放锁对象, 释放CPU使用权;在休眠的时间内,不能唤醒
  • wait(): 释放锁对象, 释放CPU使用权;在等待的时间内,能唤醒

为什么wait(),notify(),notifyAll()等方法都定义在Object类中

  • 锁对象可以是任意类型的对象