多线程详解(2)——不得不知的几个概念

多线程系列文章:

多线程详解(1)——线程基本概念

0. 简介

在多线程中可能会出现很多预想不到的现象,要理解这些现象的产生的原因,就一定要理解以下讲解的几个概念。

1. Java 线程内存模型

Java 内存模型主要定义变量的访问规则,这里的变量只是指实例变量,静态变量,并不包括局部变量,因为局部变量是线程私有的,并不存在共享。在这个模型有以下几个主要的元素:

  • 线程
  • 共享变量
  • 工作内存
  • 主内存

这几个元素之间还有几个要注意的地方:

作用处 说明
线程本身 每条线程都有自己的工作内存,工作内存当中会有共享变量的副本。
线程操作共享变量 线程只能对自己工作内存的当中的共享变量副本进行操作,不能直接操作主内存的共享变量。
不同线程间操作共享变量 不同线程之间无法直接操作对方的工作内存的变量,只能通过主线程来协助完成。

以下就是这几个元素之间的关系图:

多线程详解(2)——不得不知的几个概念_第1张图片
Java 内存模型

1.1 内存间的操作

Java 定义了 8 种操作来操作变量,这 8 种操作定义如下:

操作 作用处 说明
lock(锁定) 主内存变量 把一个变量标识成一条线程独占的状态
unlock(解锁) 主内存变量 把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read(读取) 主内存变量 把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
load(载入) 工作内存变量 把 read 操作得到的变量放入到工作内存的变量副本中
use(使用) 工作内存变量 将工作内存中的一个变量的值传递给执行引擎
assign(赋值) 工作内存变量 将执行引擎接收到的值赋给工作内存的变量
store(存储) 工作内存变量 把工作内存中一个变量的值传给主内存中,以便给随后的 write 操作使用
write(写入) 主内存变量 把 store 操作从工作内存中得到的变量的值放入主内存变量中

1.1.1 内存操作的规则

Java 内存模型操作还必须满足如下规则:

操作方法 规则
read 和 load 这两个方法必须以组合的方式出现,不允许一个变量从主内存读取了但工作内存不接受情况出现
store 和 write 这两个方法必须以组合的方式出现,不允许从工作内存发起了存储操作但主内存不接受的情况出现
assign 工作内存的变量如果没有经过 assign 操作,不允许将此变量同步到主内存中
load 和 use 在 use 操作之前,必须经过 load 操作
assign 和 store 在 store 操作之前,必须经过 assign 操作
lock 和 unlock 1. unlock 操作只能作用于被 lock 操作锁定的变量
2. 一个变量被执行了多少次 lock 操作就要执行多少次 unlock 才能解锁
lock 1. 一个变量只能在同一时刻被一条线程进行 lock 操作
2. 执行 lock 操作后,工作内存的变量的值会被清空,需要重新执行 load 或 assign 操作初始化变量的值
unlock 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中

这些操作不用记下来,只要用到的时候再回来查看一下就好。

2. 多线程中几个重要的概念

了解完 Java 的内存模型后,还需要继续理解以下几个可以帮助理解多线程现象的重要概念。

2.1 同步和异步

同步和异步的都是形容一次方法的调用。它们的概念如下:

  • 同步:调用者必须要等到调用的方法返回后才会继续后续的行为。

  • 异步:调用者调用后,不必等调用方法返回就可以继续后续的行为。

下面两个图就可以清晰表明同步和异步的区别:

多线程详解(2)——不得不知的几个概念_第2张图片
同步
多线程详解(2)——不得不知的几个概念_第3张图片
异步

2.2 并发和并行

并发和并行是形容多个任务时的状态,它们的概念如下:

  • 并发:多个任务交替运行。

  • 并行:多个任务同时运行。

其实这两个概念的的区别就是一个是交替,另一个是同时。其实如果只有一个 CPU 的话,系统是不可能并行执行任务,只能并发,因为 CPU 每次只能执行一条指令。所以如果要实现并行,就需要多个 CPU。为了加深这两个概念的理解,可以看下面两个图:

多线程详解(2)——不得不知的几个概念_第4张图片
并发
多线程详解(2)——不得不知的几个概念_第5张图片
并行

2.3 原子性

原子就是指化学反应当中不可分割的微粒。所以原子性概念如下:

原子性:在 Java 中就是指一些不可分割的操作。

比如刚刚介绍的内存操作全部都属于原子性操作。以下再举个例子帮助大家理解:

x = 1;
y = x;

以上两句代码哪个是原子性操作哪个不是?
x = 1 是,因为线程中是直接将数值 1 写入到工作内存中。
y = x 不是,因为这里包含了两个操作:

  1. 读取了 x 的值(因为 x 是变量)
  2. 将 x 的值写入到工作内存中

2.4 可见性

可见性:指一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

这里举个例子来讲解这个可见性的重要性,代码如下:

public class ThreadTest {
    
    
    private static boolean plus = true;
    private static int a;
    
    static class VisibilityThread1 extends Thread {
            
        
        public VisibilityThread1(String name) {
            setName(name);
        }
        
        @Override
        public void run() {
            while(true) {
                if(plus) {
                    a++;
                    plus = false;
                    System.out.println(getName() + " a = " + a + " plus = " + plus);
                }
            }
        }
        
    }

    static class VisibilityThread2 extends Thread {
        
        public VisibilityThread2(String name) {
            setName(name);
        }
        
        @Override
        public void run() {
            while(true) {
                if(!plus) {
                    a--;
                    plus = true;
                    System.out.println(getName() + " a = " + a + " plus = " + plus);
                }
            }

        }
        
    }
    
    
    public static void main(String[] args) {
        
        VisibilityThread1 visibilityThread1 = new VisibilityThread1("线程1");
        VisibilityThread2 visibilityThread2 = new VisibilityThread2("线程2");
        
        visibilityThread1.start();
        visibilityThread2.start();
        
    }
    
    

}

这段代码的期待输出的结果应该是以下这两句循环输出:

线程1 a = 1 plus = false
线程2 a = 0 plus = true

但是你会发现会出现如下的结果:

线程1 a = 0 plus = true
线程2 a = 1 plus = false

出现这个错误的结果是因为两条线程同时都在修改共享变量 a 和 plus。一个线程在修改共享变量时,其他线程并不知道这个共享变量被修改了,所以多线程开发中一定要关注可见性。

2.5 重排序

重排序:编译器和处理器为了优化程序性能而对指令重新排序的一种手段。
在讲解这个概念之前要先铺垫一个概念:数据依赖性。

2.5.1 数据依赖性

如果两个操作同时操作一个变量,其中一个操作还包括写的操作,那么这两个操作之间就存在数据依赖性了。这些组合操作看下表:

名称 说明 代码示例
写后读 写一个变量后,再读取这个变量 a = 1;
b = a;
写后写 写一个变量后,再写入这个变量 a = 1;
a = 2;
读后写 读取一个变量后,再写入这个变量 b = a;
a = 2;

上表这三种情况如果重排序的话就会改变程序的结果了。所以编译器和处理器并不会对这些有数据依赖性的操作进行重排序的。
注意,这里所说的数据依赖性只是在单线程的才会出现,如果多线程的话,编译器和处理器并不会有数据依赖性。

2.5.2 多线程中的重排序

这里使用简化的代码来讲解,代码如下:

int a = 0;
boolean flag = false;

// 线程1
VisibilityThread1 {
  a = 3; // 1
  flag = true; // 2
}

// 线程2
VisibilityThread2 {
  if(flag) { // 3
    a= a * 3; // 4
  }
}

这里操作 1,2 和 操作 3,4 并不存在数据依赖性,所以编译器和处理器有可能会对这些操作组合进行重排序。程序的执行的其中一种情况如下图:

多线程详解(2)——不得不知的几个概念_第6张图片
重排序

因为线程 2 中的操作 5 和 6 存在控制依赖的关系,这会影响程序执行的速度,所以编译器和处理器就会猜测执行的方式来提升速度,以上的情况就是采用了这种方式,线程 2 提前读取了 a 的值,并计算出 a * 3 的值并把这个值临时保存到重排序缓冲的硬件缓存中,等待 flag 的值变为 true 后,再把存储后的值写入 a 中。但是这就会出现我们并不想要的结果了,这种情况下,a 可能还是为 1。

2.6 有序性

如果理解了重排序后,有序性这个概念其实也是很容易理解的。
有序性:是指程序的运行顺序与编写代码的顺序一致。

3. 线程安全

理解了上述的概念之后,再来讲解线程安全的概念可能会更容易理解。

3.1 定义

线程安全就是指某个方法在多线程环境被调用的时候,能够正确处理多个线程之间的共享变量,使程序功能能够正确执行。
这里举个经典的线程安全的案例——多窗口卖票。假设有 30 张票,现在有两个窗口同时卖这 30 张票。这里的票就是共享变量,而窗口就是线程。这里的代码逻辑大概可以分为这几步:

  1. 两条线程不停循环卖票,每次卖出一张,总票数就减去一张。
  2. 如果发现总票数为 0,停止循环。

代码如下:

public class SellTicketDemo implements Runnable {

    private int ticketNum = 30;
    
    @Override
    public void run() {
        while(true) {
            
            if(ticketNum <= 0) {
                break;
            }
            
            System.out.println(Thread.currentThread().getName() +" 卖出第  " + ticketNum + " 张票,剩余的票数:" + --ticketNum);
        }
    }
    
    public static void main(String[] args) {
        
        SellTicketDemo sellTicketDemo = new SellTicketDemo();
        
        Thread thread1 = new Thread(sellTicketDemo,"窗口1");
        Thread thread2 = new Thread(sellTicketDemo,"窗口2");
        
        thread1.start();
        thread2.start();
        
    }

}

代码打印结果如下:

窗口1 卖出第  30 张票,剩余的票数:28
窗口2 卖出第  30 张票,剩余的票数:29
窗口1 卖出第  28 张票,剩余的票数:27
窗口2 卖出第  27 张票,剩余的票数:26
窗口1 卖出第  26 张票,剩余的票数:25
窗口2 卖出第  25 张票,剩余的票数:24
窗口1 卖出第  24 张票,剩余的票数:23
窗口2 卖出第  23 张票,剩余的票数:22
窗口2 卖出第  21 张票,剩余的票数:20
窗口1 卖出第  22 张票,剩余的票数:21
窗口2 卖出第  20 张票,剩余的票数:19
窗口1 卖出第  19 张票,剩余的票数:18
窗口1 卖出第  17 张票,剩余的票数:16
窗口1 卖出第  16 张票,剩余的票数:15
窗口1 卖出第  15 张票,剩余的票数:14
窗口1 卖出第  14 张票,剩余的票数:13
窗口1 卖出第  13 张票,剩余的票数:12
窗口1 卖出第  12 张票,剩余的票数:11
窗口1 卖出第  11 张票,剩余的票数:10
窗口1 卖出第  10 张票,剩余的票数:9
窗口1 卖出第  9 张票,剩余的票数:8
窗口1 卖出第  8 张票,剩余的票数:7
窗口1 卖出第  7 张票,剩余的票数:6
窗口1 卖出第  6 张票,剩余的票数:5
窗口1 卖出第  5 张票,剩余的票数:4
窗口1 卖出第  4 张票,剩余的票数:3
窗口1 卖出第  3 张票,剩余的票数:2
窗口1 卖出第  2 张票,剩余的票数:1
窗口1 卖出第  1 张票,剩余的票数:0
窗口2 卖出第  18 张票,剩余的票数:17

从以上的打印结果就可以看到,窗口1和窗口2同时都卖出第 30 张票,这和我们所期待的并不相符,这个就是线程不安全了。

4. synchronized 修饰符

那上述卖票的案例怎么才可以有线程安全性呢?其中一个办法就是用synchronized 来解决。

4.1 synchronized 代码块

4.1.1 语法格式

synchronized(obj) {
    // 同步代码块
}

4.1.2 使用 synchronized 代码块

synchronized 括号的 obj 是同步监视器,Java 允许任何对象作为同步监视器,这里使用 SellTicketDemo 实例来作为同步监视器。代码如下:

public class SellTicketDemo implements Runnable {

    private int ticketNum = 30;
    
    @Override
    public void run() {
        while(true) {
            synchronized(this) {
                if(ticketNum <= 0) {
                    break;
                }
                
                System.out.println(Thread.currentThread().getName() +" 卖出第  " + ticketNum + " 张票,剩余的票数:" + --ticketNum);
            }
        }
    }
    
    public static void main(String[] args) {
        
        SellTicketDemo sellTicketDemo = new SellTicketDemo();
        
        Thread thread1 = new Thread(sellTicketDemo,"窗口1");
        Thread thread2 = new Thread(sellTicketDemo,"窗口2");
        
        thread1.start();
        thread2.start();
        
    }

}

打印结果如下:

窗口1 卖出第  30 张票,剩余的票数:29
窗口1 卖出第  29 张票,剩余的票数:28
窗口1 卖出第  28 张票,剩余的票数:27
窗口1 卖出第  27 张票,剩余的票数:26
窗口1 卖出第  26 张票,剩余的票数:25
窗口1 卖出第  25 张票,剩余的票数:24
窗口1 卖出第  24 张票,剩余的票数:23
窗口1 卖出第  23 张票,剩余的票数:22
窗口1 卖出第  22 张票,剩余的票数:21
窗口1 卖出第  21 张票,剩余的票数:20
窗口2 卖出第  20 张票,剩余的票数:19
窗口2 卖出第  19 张票,剩余的票数:18
窗口2 卖出第  18 张票,剩余的票数:17
窗口2 卖出第  17 张票,剩余的票数:16
窗口2 卖出第  16 张票,剩余的票数:15
窗口2 卖出第  15 张票,剩余的票数:14
窗口2 卖出第  14 张票,剩余的票数:13
窗口2 卖出第  13 张票,剩余的票数:12
窗口2 卖出第  12 张票,剩余的票数:11
窗口2 卖出第  11 张票,剩余的票数:10
窗口2 卖出第  10 张票,剩余的票数:9
窗口2 卖出第  9 张票,剩余的票数:8
窗口2 卖出第  8 张票,剩余的票数:7
窗口2 卖出第  7 张票,剩余的票数:6
窗口2 卖出第  6 张票,剩余的票数:5
窗口2 卖出第  5 张票,剩余的票数:4
窗口2 卖出第  4 张票,剩余的票数:3
窗口2 卖出第  3 张票,剩余的票数:2
窗口2 卖出第  2 张票,剩余的票数:1
窗口2 卖出第  1 张票,剩余的票数:0

可以看到现在的结果就是正确的了。

4.2 synchronized 方法

4.2.1 语法格式

[修饰符] synchronized [返回值] [方法名](形参...) {
        
}

4.2.2 使用 synchronized 方法

使用同步方法非常简单,直接用 synchronized 修饰多线程操作的方法即可,代码如下:

public class SellTicketDemo implements Runnable {

    private int ticketNum = 30;
    
    @Override
    public void run() {
        while(true) {

            sellTicket();
            
        }
    }
    
    public synchronized void sellTicket() {
        if(ticketNum <= 0) {
            return;
        }
        
        System.out.println(Thread.currentThread().getName() +" 卖出第  " + ticketNum + " 张票,剩余的票数:" + --ticketNum);
    }
    
    public static void main(String[] args) {
        
        SellTicketDemo sellTicketDemo = new SellTicketDemo();
        
        Thread thread1 = new Thread(sellTicketDemo,"窗口1");
        Thread thread2 = new Thread(sellTicketDemo,"窗口2");
        
        thread1.start();
        thread2.start();
        
    }

}

打印如下:

窗口1 卖出第  30 张票,剩余的票数:29
窗口1 卖出第  29 张票,剩余的票数:28
窗口1 卖出第  28 张票,剩余的票数:27
窗口1 卖出第  27 张票,剩余的票数:26
窗口1 卖出第  26 张票,剩余的票数:25
窗口1 卖出第  25 张票,剩余的票数:24
窗口1 卖出第  24 张票,剩余的票数:23
窗口1 卖出第  23 张票,剩余的票数:22
窗口1 卖出第  22 张票,剩余的票数:21
窗口1 卖出第  21 张票,剩余的票数:20
窗口1 卖出第  20 张票,剩余的票数:19
窗口2 卖出第  19 张票,剩余的票数:18
窗口2 卖出第  18 张票,剩余的票数:17
窗口2 卖出第  17 张票,剩余的票数:16
窗口2 卖出第  16 张票,剩余的票数:15
窗口2 卖出第  15 张票,剩余的票数:14
窗口2 卖出第  14 张票,剩余的票数:13
窗口2 卖出第  13 张票,剩余的票数:12
窗口2 卖出第  12 张票,剩余的票数:11
窗口2 卖出第  11 张票,剩余的票数:10
窗口2 卖出第  10 张票,剩余的票数:9
窗口2 卖出第  9 张票,剩余的票数:8
窗口2 卖出第  8 张票,剩余的票数:7
窗口2 卖出第  7 张票,剩余的票数:6
窗口2 卖出第  6 张票,剩余的票数:5
窗口2 卖出第  5 张票,剩余的票数:4
窗口2 卖出第  4 张票,剩余的票数:3
窗口2 卖出第  3 张票,剩余的票数:2
窗口2 卖出第  2 张票,剩余的票数:1
窗口2 卖出第  1 张票,剩余的票数:0

参考文章和书籍:
java并发之原子性、可见性、有序性
Java内存访问重排序的研究
Java并发编程的艺术
Java并发编程实战
实战Java高并发程序设计
深入理解Java虚拟机

你可能感兴趣的:(多线程详解(2)——不得不知的几个概念)