Java多线程并发编程

时间:2022-09-01 18:00:23

Thread和Runnable

  • Runnable接口可以避免继承自Thread类的单继承的局限性。
  • Runnable的代码可以被多个线程(Thread的实例)所共享,适合于多个线程共享资源(其实就是持有同一个runnable实例)的情况。

以火车站买票为例,分别以继承Thread类和实现Runnable接口这两种方式来模拟3个线程卖5张票:

  使用Thread类模拟卖票

 1 class MyThread extends Thread{
 2     
 3     private int ticketCount = 5; // 5张票
 4     private String name;        // 窗口(线程的名字)
 5     
 6     public MyThread(String name) {
 7         this.name = name;
 8     }
 9     
10     @Override
11     public void run() {
12         while (ticketCount > 0) {
13             System.out.println(name + "窗口卖了1张票,剩余票数:" + --ticketCount);
14             try {
15                 Thread.sleep(50);
16             } catch (InterruptedException e) {
17                 e.printStackTrace();
18             }
19         }
20     }
21 }
22 
23 public class TicketThreadTest {
24 
25     public static void main(String[] args) {
26         
27         new MyThread("窗口1").start(); // 创建了3个MyThread,每个线程有自己的独立的资源
28         new MyThread("窗口2").start();
29         new MyThread("窗口3").start();
30     }
31 
32 }

  运行结果:

Java多线程并发编程

  使用Runnable接口模拟卖票

 1 class MyThread implements Runnable {
 2 
 3     private int ticketCount = 5;
 4 
 5     @Override
 6     public void run() {
 7         while (ticketCount > 0) {
 8             System.out.println(Thread.currentThread().getName()+"卖了1张票,剩余票数:" + --ticketCount);
 9             try {
10                 Thread.sleep(50);
11             } catch (InterruptedException e) {
12                 e.printStackTrace();
13             }
14         }
15     }
16 
17 }
18 
19 public class TicketThreadTest {
20 
21     public static void main(String[] args) {
22 
23         MyThread mt = new MyThread();
24         new Thread(mt,"窗口1").start(); // 3个线程使用的是同一个Runnable实例中的资源
25         new Thread(mt,"窗口2").start();
26         new Thread(mt,"窗口3").start();
27     }
28 
29 }

  运行结果:

Java多线程并发编程

  之所以出现运行结果是4,2,3,1,0而不是4,3,2,1,0是因为线程将票数减去1之后还没有来得及打印又立即被其他线程抢占的CPU。如果要打印顺序一致,需要采用同步。

  在使用Runnable接口的时候需要注意的是:要想让所有的线程共享资源,传递给Thread的Runnable接口应该是同一个实例,例如以下的代码并不共享资源

1         MyThread mt1 = new MyThread();
2         MyThread mt2 = new MyThread();
3         MyThread mt3 = new MyThread();
4 
5         new Thread(mt1,"窗口1").start();
6         new Thread(mt2,"窗口2").start();
7         new Thread(mt3,"窗口3").start();    // 3个线程有各自的资源

线程的生命周期

  • 创建:对应于new。
  • 就绪:调用start()方法。注意:此时线程仅仅是加入了就绪队列,等待获取CPU执行权,具备了运行的条件,但是不一定已经开始运行。
  • 运行:处于就绪状态的线程一旦获取了CPU便进行入了运行态,执行run()方法中的逻辑。
  • 终止:run()方法正常完成或者线程调用stop()方法。
  • 阻塞:一个运行中的线程由于某种原因让出了CPU资源,暂停了自己的执行,例如调用了sleep()、wait()、join()方法。

Java多线程并发编程

守护线程

  java中的线程分为2类。

  • 用户线程:运行在前台,执行具体的任务。例如:程序的主线程、连接网络的子线程都是用户线程。
  • 守护线程:运行在后台,为其他前台线程提供服务。一旦所有的用户线程都结束运行,守护线程就会随JVM一起结束工作。例如:数据库连接池中的监测线程,JVM启动后的监测线程、还有最常见的GC线程。

  设置守护线程只需要使用Thread类中提供的setDaemon(true)方法即可。注意:

  • 该方法必须在start()方法之前调用,否则会抛出IllegalThreadStateException。
  • 在守护线程中产生的新线程也会自动成为守护线程。
  • 并不是所有的任务都可以分配给守护线程来执行的,例如:读写操作或者计算逻辑。(因为它结束的时间是不可预知的)

  下面模拟这样一个场景:守护线程在很长的一段时间内不断向文件中写数据,主线程阻塞等待来自键盘的输入,一旦主线程获取到了用户的输入,这时候主线程的阻塞就会解除掉,主线程继续运行直到结束。一旦主线程结束,用户线程就没有了,这时候即使数据还没有写完,守护线程也会随JVM一起结束运行。

 1 import java.io.File;
 2 import java.io.FileOutputStream;
 3 import java.util.Scanner;
 4 
 5 class DaemonThread implements Runnable{
 6 
 7     @Override
 8     public void run() {
 9         System.out.println("进入守护线程:" + Thread.currentThread().getName());
10         
11         try {
12             writeToFile();                                        // 向文件中写数据
13         } catch (Exception e) {
14             e.printStackTrace();
15         }
16         
17         System.out.println("退出守护线程:" + Thread.currentThread().getName());
18     }
19 
20     /**
21      * 在500秒之内不断向文件中写入内容
22      */
23     private void writeToFile() throws Exception{
24         
25         final String LINE_SEPARATOR = System.getProperty("line.separator");
26         FileOutputStream fos = new FileOutputStream(new File("deamon.txt"), true);
27             
28         int count = 0;
29         while (count < 500) {
30             fos.write((LINE_SEPARATOR + "word" + count).getBytes());
31             System.out.println("守护线程向文件中写入了world" + count++);
32             Thread.sleep(1000);                        // 每写一次休眠1s,保证线程不那么快结束
33         }
34         fos.close();
35     }
36     
37 }
38 
39 public class DaemonThreadTest {
40 
41     public static void main(String[] args) {
42 
43         System.out.println("进入主线程:" + Thread.currentThread().getName());
44         
45         DaemonThread daemonThread = new DaemonThread();
46         Thread thread = new Thread(daemonThread);
47         thread.setDaemon(true);                            // 设置为守护线程
48         thread.start();
49         
50         Scanner scanner = new Scanner(System.in);
51         scanner.next();                                    // 主线程阻塞,一旦执行了输入,主线程就会解除阻塞,打印出最下面语句,退出运行
52         
53         System.out.println("退出主线程:" + Thread.currentThread().getName());
54     }
55 
56 }

 Java多线程并发编程

  由以上的演示可以知道:当用户线程停止运行,守护线程也就终止了。守护线程原先的意图是向文件中写入500次,但是我们在写入第7次的时候解除了键盘的阻塞,导致主线程结束(用户线程),守护线程自然而然结束了,仅仅向文件中写入了部分数据。

使用jstack生成线程快照

  Java多线程并发编程

  jstack主要是用来生成JVM当前时刻线程的快照的(threaddump,即:当前进程中所有的线程信息),帮助我们分析出程序问题的原因,如:长时间停顿、CPU占用率过高等。

Java多线程并发编程

  找到进程的pid可以使用任务管理器——查看——选择列——勾选PID。

  运行前面的DaemonThreadTest,使用任务管理器查看pid。

Java多线程并发编程

  如果在Eclipse中运行,则进行的名字为javaw。记录下该PID在jstack中执行jstack -l pid.即生成了当前时刻的线程快照

Java多线程并发编程

Java多线程并发编程

Java多线程并发编程

可见性

  • 可见性:指的是一个线程对共享变量的修改能够被其他线程及时看到。
  • 共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。

Java内存模型(JMM)

  Java Memory Model描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。

  • 所有的变量都存储在主内存中。
  • 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)。

Java多线程并发编程

  JMM中的2条规定:

  • 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写。
  • 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

  共享变量可见性原理的实现

  线程1对共享变量的修改要想被线程2及时看到,必须经过以下的2个步骤:

  1. 把工作内存1中更新过的共享变量刷新到主内存中。
  2. 将主内存中最新的共享变量的值更新到工作内存2中。

Java多线程并发编程

  java在语言层次(并不包括jdk1.5之后引入的java.util.concurrent实现可见性)上提供了2种可见性的实现:

  • synchronized
  • volatile
  • final(一旦赋值,不许更改)

synchronized实现可见性

  synchronized能够实现原子性(同步)和内存可见性。JMM中关于synchronized的2条规定:

  • 线程解锁前,必须把共享变量的最新值刷新到主内存中。——保证了线程退出同步块之后,主内存中的值最新。
  • 线程加锁时,将清空工作内存*享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(注意:加锁和解锁需要是同一把锁)。

  有了以上的2条就能够保证线程解锁前对共享变量的修改在下次加锁时对其他线程是可见的。

线程执行互斥代码的过程

  1. 获得互斥所;
  2. 清空工作内存;
  3. 从主内存中拷贝变量的最新副本到工作内存;
  4. 执行锁内部的代码;
  5. 将更改后的共享变量的值刷新到主内存中;
  6. 释放互斥锁。

指令重排序

  代码的书写顺序与实际执行的顺序不同,指令重排序是编译器或者处理机为了提高程序性能而做的优化。指令重排序主要有3种:

  • 编译器优化重排序(编译器优化,调整代码顺序使其更符合处理机)
  • 指令集并行重排序(处理机优化,例如多核技术)
  • 内存系统重排序(处理机优化,主要针对读写缓存)

  指令重排序可能导致以下的结果:

Java多线程并发编程

as-if-serial语义

  无论如何重排序,程序的执行结果应该和代码顺序执行的结果一致。(java编译器、运行时和处理机都会保证Java在单线程下遵守as-if-serial语义)

Java多线程并发编程

  例如有以下的程序:

 1 package org.gpf;
 2 
 3 public class SynchronizedDemo{
 4     
 5     // 共享变量
 6     private boolean ready = false;
 7     private int result = 0;
 8     private int number = 1;
 9     
10     /**
11      * 写操作
12      */
13     public void write(){
14         ready = true;                    // 1.1
15         number = 2;                        // 1.2
16     }
17     /** 读操作*/
18     public void read(){
19         
20         if (ready)                        // 2.1
21             result = number * 3;        // 2.2
22         System.out.println("result的值是:" + result);
23     }
24     
25     private class ReadWriteThread extends Thread{
26         
27         private boolean flag;
28 
29         /**
30          * 根据构造方法中传入的布尔值确定是读操作还是写操作
31          */
32         public ReadWriteThread(boolean flag) {
33             this.flag = flag;
34         }
35         
36         @Override
37         public void run() {
38             if (flag) 
39                 write();
40             else
41                 read();
42         }
43         
44     }
45     
46     public static void main(String[] args) {
47         
48         SynchronizedDemo syncDemo = new SynchronizedDemo();
49         
50         syncDemo.new ReadWriteThread(true).start();    // 启动线程进行写操作
51         try {
52             Thread.sleep(1000);
53         } catch (InterruptedException e) {
54             e.printStackTrace();
55         }
56         syncDemo.new ReadWriteThread(false).start();// 启动线程进行读操作
57     }
58 }

  假设关键语句的执行顺序是1.1-->2.1-->2.2-->1.2,则result = 3.

  假设关键语句的执行顺序是1.2-->2.1-->2.2-->1.1,则result = 0.

  类似以上的情况有很多,例如2.1和2.2就有可能进行重排序(因为2.1和2.2没有数据依赖关系,只有数据依赖关系才会禁止进行重排序)。2.1和2.2进行重排序后等同于如下的代码:

1 int mid = number * 3;
2 if (ready) {
3     result = mid;
4 }

导致共享变量不可见(线程不安全)的原因

  • 线程的交叉执行;
  • 重排序结合线程交叉执行;
  • 共享变量更新后的值没有在工作内存与主内存之间进行及时更新。

安全的代码应该是下面的:

 1 /**
 2  * 写操作
 3  */
 4 public synchronized void write(){
 5     ready = true;                    // 1.1
 6     number = 2;                      // 1.2
 7 }
 8 
 9 /**
10  * 读操作
11  */
12 public synchronized void read(){
13     
14     if (ready)                      // 2.1
15         result = number * 3;        // 2.2
16     System.out.println("result的值是:" + result);
17 }

  加入synchronized之所以能过解决内存可见性的原理:

  • synchronized相当于一把锁,保证了锁内部代码的原子性。正是由于原子性保证了线程不可能交叉执行。
  • synchronized保证了共享变量在工作内存和主内存之间进行及时更新。

  之所以出现不加synchronized关键字,打印的result的结果也是6这种情况是因为:synchronized关键字是通过2条JMM规范实现的内存可见性,如果有synchronized关键字就一定能过保证内存可见性,JMM并没有说不加synchronized关键字共享内存就一定不可见——即使不加synchronized关键字,也是有可能会有可见性(并且大多数情况下是这样,主要是编译器做了一些优化,揣摩程序的用途,实现我们想要的结果)。但是尽管如此,最好还是在并发编程的时候我们自己实现可见性。

volatile实现内存可见性

  volatile关键字能够保证volatile变量的可见性,但是不能保证volatile变量复合操作的原子性。

volatile实现内存可见性的原理

  • 深入来说:volatile是通过加入内存屏障和禁止重排序优化来实现的。
    • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令,它会将CPU写缓冲区的缓存强制刷新到主内存中。
    • 对volatile变量执行读操作时,会在读操作后加入一条load屏障指令,强制使缓冲区的缓存失效。
  • 通俗地讲:volatile变量在每次线程访问时,都强迫从主内存中重读该变量的值,而当该变量变化时,又会强迫线程将最新的值刷新到主内存。这样在任何时刻,不同的线程总能看到该变量的最新值。  

线程写volatile变量的过程

  1. 改变线程工作内存中volatile变量副本的值;
  2. 将改变后的副本的值从工作内存刷新到主内存。

线程读volatile变量的过程

  1. 从主内存中读取volatile变量的最新值到线程的工作内存中;
  2. 从工作内存中读取volatile变量的副本。

volatile不能保证volatile变量复合操作的原子性

1 private volatile int number = 0;
2 number++;

  以上的代码并不是原子操作,以上的number++实际上有3个原子操作:①读取number的值;②将number的值加1;③写入最新的number的值。

 1 public class VolatileDemo {
 2 
 3     private volatile int number = 0; //    定义volatile变量,改变对其他线程可见
 4     
 5     public int getNumber() {
 6         return number;
 7     }
 8 
 9     /**
10      * 该方法完成number的自增操作
11      */
12     public void increase(){
13         try {
14             Thread.sleep(100);
15         } catch (InterruptedException e) {
16             e.printStackTrace();
17         }
18         this.number++;    // 该操作不是原子操作
19     }
20 
21     public static void main(String[] args)  {
22         
23         final VolatileDemo volDemo = new VolatileDemo();
24         
25         // 启动500个线程对number进行自增操作
26         for (int i = 0; i < 500; i++) {
27             new Thread(new Runnable() {
28                 
29                 @Override
30                 public void run() {
31                     volDemo.increase();
32                 }
33             }).start();
34         }
35         
36         // 如果还有子线程在运行,主线程就让出CPU资源,直到所有的子线程全部运行完毕,主线程再继续运行
37         while (Thread.activeCount() > 1) {
38             Thread.yield();
39         }
40         
41         System.out.println("number = " + volDemo.getNumber());
42     }
43 }

  如果volatile变量能够保证原子操作,那么500个线程运行结束number的值应该是500,运行以上程序:发现number的值可能不是500而是480~500左右.也就是说volatile并不能保证原子操作。

  程序分析:假设在某一时刻number的值是5

Java多线程并发编程

  但是在线程A的工作内存中number = 5.

Java多线程并发编程

  此时线程A和主内存中的number的值都是6.——出现了两个线程对number进行增加操作,但是number的值只增加了1,所以会出现以上的number < 500的情况。

解决方案(保证number++的原子性即可):

  • 使用synchronized关键字;
  • 使用JDK1.5提供的java.util.concurrent.locks.ReentrantLock;
  • 使用JDK1.5提供的java.util.concurrent.atomic.AtomicInteger。

解决方案一:使用synchronized关键字(此时就不需要使用volatile变量了)

 1 public class VolatileDemo {
 2 
 3     private int number = 0; //    共享变量
 4     
 5     public int getNumber() {
 6         return number;
 7     }
 8 
 9     /**
10      * 该方法完成number的自增操作
11      */
12     public void increase(){
13         
14         try {
15             Thread.sleep(100);
16         } catch (InterruptedException e) {
17             e.printStackTrace();
18         }
19         
20         synchronized (this) {
21             this.number++;        // 缩小锁的粒度,直接加在方法上会导致等待时间过长
22         }
23     }
24 
25     public static void main(String[] args)  {
26         
27         final VolatileDemo volDemo = new VolatileDemo();
28         
29         // 启动500个线程对number进行自增操作
30         for (int i = 0; i < 500; i++) {
31             new Thread(new Runnable() {
32                 
33                 @Override
34                 public void run() {
35                     volDemo.increase();
36                 }
37             }).start();
38         }
39         
40         // 如果还有子线程在运行,主线程就让出CPU资源,直到所有的子线程全部运行完毕,主线程再继续运行
41         while (Thread.activeCount() > 1) {
42             Thread.yield();
43         }
44         
45         System.out.println("number = " + volDemo.getNumber());
46     }
47 }

  注意:在以上的程序中同步放在number++上而不是放在同步方法上,原因是缩小锁的粒度,避免过长时间的等待。

解决方案二:使用jdk1.5之后的并发包中的可重锁

 1 import java.util.concurrent.locks.Lock;
 2 import java.util.concurrent.locks.ReentrantLock;
 3 
 4 public class VolatileDemo {
 5 
 6     private int number = 0;                 // 共享变量
 7     private Lock lock = new ReentrantLock();// 可重互斥锁    
 8     
 9     public int getNumber() {
10         return number;
11     }
12 
13     /**
14      * 该方法完成number的自增操作
15      */
16     public void increase(){
17         
18         try {
19             Thread.sleep(100);
20         } catch (InterruptedException e) {
21             e.printStackTrace();
22         }
23         
24         lock.lock();        // 加锁
25         try {
26             this.number++;    // 将操作放在try中
27         } finally{    
28             lock.unlock();    // 在finally中释放锁    
29         }
30         
31     }
32 
33     public static void main(String[] args)  {
34         
35         final VolatileDemo volDemo = new VolatileDemo();
36         
37         // 启动500个线程对number进行自增操作
38         for (int i = 0; i < 500; i++) {
39             new Thread(new Runnable() {
40                 
41                 @Override
42                 public void run() {
43                     volDemo.increase();
44                 }
45             }).start();
46         }
47         
48         // 如果还有子线程在运行,主线程就让出CPU资源,直到所有的子线程全部运行完毕,主线程再继续运行
49         while (Thread.activeCount() > 1) {
50             Thread.yield();
51         }
52         
53         System.out.println("number = " + volDemo.getNumber());
54     }
55 }

  注意:在进行互斥资源的操作时先加锁,将操作放在try中,在finally中释放锁。

解决方案三:使用原子方式更新int的值(AtomicInteger)

 1 import java.util.concurrent.atomic.AtomicInteger;
 2 
 3 public class VolatileDemo {
 4 
 5     private AtomicInteger atomicInteger = new AtomicInteger(0);    // 共享变量,可以用原子方式更新的 int值
 6     public int getNumber() {
 7         return atomicInteger.get();
 8     }
 9 
10     /**
11      * 该方法完成number的自增操作
12      */
13     public void increase(){
14         
15         try {
16             Thread.sleep(100);
17         } catch (InterruptedException e) {
18             e.printStackTrace();
19         }
20         
21         atomicInteger.incrementAndGet();    // 以原子的方式进行自增
22         
23     }
24 
25     public static void main(String[] args)  {
26         
27         final VolatileDemo volDemo = new VolatileDemo();
28         
29         // 启动500个线程对number进行自增操作
30         for (int i = 0; i < 500; i++) {
31             new Thread(new Runnable() {
32                 
33                 @Override
34                 public void run() {
35                     volDemo.increase();
36                 }
37             }).start();
38         }
39         
40         // 如果还有子线程在运行,主线程就让出CPU资源,直到所有的子线程全部运行完毕,主线程再继续运行
41         while (Thread.activeCount() > 1) {
42             Thread.yield();
43         }
44         
45         System.out.println("number = " + volDemo.getNumber());
46     }
47 }

使用volatile的注意事项

  由于volatile只能保证可见性不能保证原子性,所以要安全使用volatile变量,必须同时满足以下条件:

  1. 对变量的写入操作不依赖当前值(例如:number++count*=5不满足条件,boolean变量满足条件);
  2. 该变量没有包含在具有其他变量的不变式中(例如low<high不满足)。

synchronized与volatile的比较

  • volatile不需要加锁,比synchronized更轻量级,不会阻塞线程;
  • 从内存性角度来讲,volatile读相当于加锁,volatile写相当于解锁

Q&A

  Q1:即使没有保证可见性的措施(没有同步、volatile和final),很多时候共享变量依然能够在主内存和工作内存见得到及时的更新?

  A1:一般只有在短时间内高并发的情况下才会出现变量得不到及时更新的情况,因为CPU在执行时会很快地刷新缓存,所以一般情况下很难看到这种问题。

PS:

  对64位的变量(long或者double)变量的读写可能不是原子操作,因为JMM允许JVM将没有被volatile修饰的64位数据类型的读写操作划分为2次32位的读写操作来进行,可能会导致读取“半个变量”的问题,解决方案是加volatile关键字。