多线程编程需要考虑最多的问题就是线程安全问题,线程安全指的就是共享资源的同步问题。所谓同步就是指多个线程在同一个时间段内只能由一个线程进行操作,其他线程需要等待正在操作的线程完成操作之后才可以继续执行。

1

深入了解Thread类和Runnable接口

关于多线程共享资源的几个安全性问题_Java

 

通过继承Thread类和实现Runnable接口都可以实现多线程的创建,那么两者有哪些联系和区别呢?

01、Thread类的定义

以下代码片段,摘自JDK源码里Thread类的部分定义:

public class Thread implements Runnable {
  // 省略
  private Runnable target;
  
  public Thread(Runnable target, String name) {
          init(null, target, name, 0);
      }
  
  private void init(ThreadGroup g, Runnable target, String name, long stackSize) {
          // 省略
          this.target = target;
          // 省略
      }
      
  public void run() {
          if (target != null) {
              target.run();
          }
      }
  // 省略
}

通过以上Thread类的定义,可以看出,Thread类是Runnable接口的子类,但在Thread类中并没有完全地实现Runnable接口中的run方法。Thread类中的run方法调用的是target的run方法,而target则是Runnable接口的子类,所以通过继承Thread类实现多线程,则该线程子类必须覆写run方法。

02、通过实现Runnable接口创建多线程的UML图

关于多线程共享资源的几个安全性问题_Java_02

通过以上的UML类图,我们可以发现,Thread类和MyThread类都实现了Runnable接口,通过实现Runnable接口来创建线程类MyThread,其实就是把MyThread类放到了Thread类的target变量中,线程启动后,实际上是调用的target的run方法,也就是MyThread类中的run方法,所以说MyThread类必须覆写run方法。这种操作模式是典型的代理设计模式。

通过以上2个部分的说明,我们可以很明显地看出Thread类和Runnable接口的联系,那就是Thread类实现了Runnable接口。

03、通过继承Thread类实现的多线程能实现资源共享吗

答案是不能,如果一个类继承Thread类的话,它就不适合多个线程之间共享资源。

示例:通过继承Thread类的方式实现模拟售票场景,假设有5张电影票,两个窗口同时卖这5张电影票。

public class SaleTicketTask extends Thread {


  private int ticketNumbers = 5; // 电影票数量


  public SaleTicketTask(String name) {
    super(name);
  }


  @Override
  public void run() {
    while (ticketNumbers > 0) {
      System.out.println(Thread.currentThread().getName()
          + "卖票:ticketNumbers=" + ticketNumbers--);
    }
  }
}


// 测试类
public class SaleTicketTaskTest {
  public static void main(String[] args) {
    // 创建2个售票窗口
    SaleTicketTask st1 = new SaleTicketTask("窗口1");
    SaleTicketTask st2 = new SaleTicketTask("窗口2");
    // 开始售票
    st1.start();
    st2.start();
  }
}


程序运行结果(程序运行的结果可能有多个):
窗口1卖票:ticketNumbers=5
窗口2卖票:ticketNumbers=5
窗口2卖票:ticketNumbers=4
窗口2卖票:ticketNumbers=3
窗口1卖票:ticketNumbers=4
窗口2卖票:ticketNumbers=2
窗口1卖票:ticketNumbers=3
窗口2卖票:ticketNumbers=1
窗口1卖票:ticketNumbers=2
窗口1卖票:ticketNumbers=1

从以上的程序运行结果来看,通过继承Thread类实现的多线程,每一个窗口都卖了5张电影票,没有达到5张电影票资源共享的目的。

04、通过实现Runnable接口创建的多线程可以实现资源共享

如果一个类继承Thread类的话,它就不适合多个线程之间共享资源,但是一个类实现了Runnable接口的话,就可以方便的实现资源共享。

示例:通过实现Runnable接口的方式实现模拟售票场景,假设有5张电影票,两个窗口同时卖这5张电影票。

public class SaleTicketTask1 implements Runnable {
  private int ticketNumbers = 5; // 电影票数量
  @Override
  public void run() {
    while (ticketNumbers > 0) {
      System.out.println(Thread.currentThread().getName()
          + "卖票,ticketNumbers=" + ticketNumbers--);
    }
  }
}


// 测试类
public class SaleTicketTask1Test {
  public static void main(String[] args) {
    SaleTicketTask1 st = new SaleTicketTask1(); // 创建Runnable实例
    // 模拟两个售票窗口
    Thread sale1 = new Thread(st, "窗口1");
    Thread sale2 = new Thread(st, "窗口2");
    // 两个售票窗口同时售票
    sale1.start();
    sale2.start();
  }
}


程序运行结果(程序运行的结果可能有多个):
窗口1卖票,ticketNumbers=5
窗口2卖票,ticketNumbers=4
窗口1卖票,ticketNumbers=3
窗口2卖票,ticketNumbers=2
窗口1卖票,ticketNumbers=1

从以上的运行结果来看,通过实现Runnable接口实现的多线程,两个窗口共卖了5张电影票,达到了5张电影票资源共享的目的。

实现Runnable接口相对于继承Thread类来说,有以下3个优势:

1、适合多个相同程序代码的线程去处理同一资源的情况;

2、可以避免由于Java的单继承特性带来的局限;

3、代码能够被多个线程共享,代码与数据可以独立;

因此,在开发中建议通过实现Runnable接口来创建多线程。

2

演示线程安全问题

关于多线程共享资源的几个安全性问题_Java

上面通过实现Runnable接口实现的线程,达到了两个窗口卖出5张电影票的目的,那么请大家思考,以上的程序是线程安全的吗?

答案是以上的程序不是线程安全的,可能出现重复售票的情况。

01、演示共享资源的线程不安全问题

我们可以在上面的SaleTicketTask1类中的run方法中加入一段线程休眠的代码,来放大这种线程不安全性,再次观察程序的运行情况。

public class SaleTicketTask1 implements Runnable {
  private int ticketNumbers = 5; // 电影票数量
  @Override
  public void run() {
    while (ticketNumbers > 0) {
      try {
        Thread.sleep(10);  // 线程休眠,以便更好地观察线程的不安全行
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      System.out.println(Thread.currentThread().getName()
          + "卖票,ticketNumbers=" + ticketNumbers--);
    }
  }
}


再次运行上面的测试程序SaleTicketTask1Test类,观察程序运行结果:


第一次运行结果(程序运行的结果可能有多个):
窗口2卖票,ticketNumbers=5
窗口1卖票,ticketNumbers=5
窗口2卖票,ticketNumbers=4
窗口1卖票,ticketNumbers=3
窗口2卖票,ticketNumbers=2
窗口1卖票,ticketNumbers=2
窗口1卖票,ticketNumbers=1
窗口2卖票,ticketNumbers=1


第二次运行结果(程序运行的结果可能有多个):
窗口2卖票,ticketNumbers=5
窗口1卖票,ticketNumbers=4
窗口2卖票,ticketNumbers=3
窗口1卖票,ticketNumbers=2
窗口2卖票,ticketNumbers=1
窗口1卖票,ticketNumbers=0

通过上面两次程序的运行结果很明显的可以看出,两个线程窗口售票出现了共享资源(5张电影票)不安全的情况,第一次的程序卖出了3张重复票,第二次的程序票已近卖完了,依然又卖出了一张票号为0的票。

3

解决线程安全问题

关于多线程共享资源的几个安全性问题_Java

在多线程编程中,遇到多个线程要操作同一个资源的时候,就有可能出现共享资源的同步问题。要想解决这样的问题,就必须使用同步。

所谓同步就是指多个线程在同一个时间段内只能由一个线程进行操作,其他线程需要等待正在操作的线程完成操作之后才可以继续执行。

在Java编程中,实现同步操作的关键字是synchronized,解决资源共享的同步操有两种方法:一是使用同步代码块,二是使用同步方法。

01、使用同步代码块解决共享资源的线程不安全问题

代码块就是指用“{}”括起来的一段代码,如果再代码块上加上synchronized关键字,则此代码块就是同步代码块。

同步代码块的格式如下:

synchronized ( 同步对象 ){
    // 需要同步的代码;
}

在使用同步代码块时必须指定一个同步对象,一般将当前对象(this)设置成同步对象。

示例1:使用同步代码块解决以上售票的同步问题

public class SaleTicketTask1 implements Runnable {
  private int ticketNumbers = 5; // 电影票数量
  @Override
  public void run() {
  // 同步代码块,run方法开始的时候就加入了synchronized同步
    synchronized (this) { 
      while (ticketNumbers > 0) {
        try {
          Thread.sleep(10); // 线程休眠,以便更好地观察线程的不安全行
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()
            + "卖票,ticketNumbers=" + ticketNumbers--);
      }
    }
  }
}


运行测试程序SaleTicketTask1Test类,
程序运行结果如下(程序运行的结果可能有多个):
窗口1卖票,ticketNumbers=5
窗口1卖票,ticketNumbers=4
窗口1卖票,ticketNumbers=3
窗口1卖票,ticketNumbers=2
窗口1卖票,ticketNumbers=1

通过上面的结果可以看出,加入同步代码块确实解决了共享资源电影票安全性的问题,但是上面的示例代码,在run方法开始的时候就加入了synchronized同步,导致只有一个窗口在卖票,因此以上的代码是不完美的。


示例2:使用同步代码块解决以上售票的同步问题

public class SaleTicketTask1 implements Runnable {
  private int ticketNumbers = 5; // 电影票数量
  @Override
  public void run() {
    while (ticketNumbers > 0) {
      try {
        Thread.sleep(10); // 线程休眠,以便更好地观察线程的不安全行
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      synchronized (this) { // 同步代码块
        if (ticketNumbers > 0) { // 再次判断是否还有票
          System.out.println(Thread.currentThread().getName()
              + "卖票,ticketNumbers=" + ticketNumbers--);
        }
      }
    }
  }
}


运行测试程序SaleTicketTask1Test类,
程序运行结果如下(程序运行的结果可能有多个):
窗口1卖票,ticketNumbers=5
窗口2卖票,ticketNumbers=4
窗口1卖票,ticketNumbers=3
窗口2卖票,ticketNumbers=2
窗口1卖票,ticketNumbers=1

说明,上面的程序通过同步代码块,完成了共享资源的同步安全性问题。

02、使用同步方法解决共享资源的线程不安全问题

同步方法顾名思义就是在方法的声明的时候加上synchronized关键字。

Java中方法定义的完整格式如下:

访问权限{public|default|protected|private} [final] [static] 
[synchronized] 返回值类型|void 方法名称(参数类型 参数名称, ...)
[throws Exception1,Exception2] {
  // 方法体
  [return [返回值|返回调用处]] ;
}

示例:使用同步方法解决以上售票的同步问题

public class SaleTicketTask2 implements Runnable {
  private int ticketNumbers = 5; // 电影票数量
  @Override
  public void run() {
    while (ticketNumbers > 0) {
      try {
        Thread.sleep(10); // 线程休眠,以便更好地观察线程的不安全行
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      sale();
    }
  }


  // 同步方法
  public synchronized void sale() {
    if (ticketNumbers > 0) { // 再次判断是否还有票
      System.out.println(Thread.currentThread().getName()
          + "卖票,ticketNumbers=" + ticketNumbers--);
    }
  }
}


// 测试类
public class SaleTicketTask2Test {
  public static void main(String[] args) {
    SaleTicketTask2 st = new SaleTicketTask2(); // 创建Runnable实例
    // 模拟两个售票窗口
    Thread sale1 = new Thread(st, "窗口1");
    Thread sale2 = new Thread(st, "窗口2");
    // 两个售票窗口同时售票
    sale1.start();
    sale2.start();
  }
}


程序运行结果(程序运行的结果可能有多个):
窗口2卖票,ticketNumbers=5
窗口1卖票,ticketNumbers=4
窗口1卖票,ticketNumbers=3
窗口2卖票,ticketNumbers=2
窗口2卖票,ticketNumbers=1

说明,上面的程序通过同步方法,也能完成共享资源的同步安全性问题。

以上内容对多线程的两种实现方式的联系和区别、对多线程编程中共享资源同步问题的解决进行了说明,希望大家可以熟练掌握。

追梦人

 

关于多线程共享资源的几个安全性问题_Java_05