Java 多线程(3)---- 线程的同步(上)

本文标题大纲:

前言

我们在前面两篇文章中分别看了一下 Java 线程的一些概念、用法和对于线程控制(开始、暂停、停止)等,并对其中的一些易错点进行了总结,如果你是对这些概念还是还不是太熟悉,建议先看一下前面的文章:Java 多线程(1)— 初识线程Java 多线程(2) — 线程的控制。这篇文章我们来继续讨论 Java 多线程 — 线程的同步。

Java 内存模型

在开始介绍线程同步之前,我们必须要对 Java 中的内存模型(这里是针对线程的角度上来看)有一个大概的理解。这个是理解后面的内容的基础。
我们先从计算机的角度来看这个问题:我们都知道,计算机的 CPU 是整个计算机的 “心脏”,也是衡量一台计算机性能的一个重要指标,其 CPU 运算速度越快,相对来说这台计算机性能就越好。也正是因为计算机 CPU 的运算速度非常快,而相对来说主内存(可以理解成计算机的内存条)的读取和写入速度就很慢了,那么如果不另外采取手段弥补两者的速度差距,那么 CPU 再好的计算机的性能也会被内存的速度所影响。为了解决这个问题,人们在计算机 CPU 和内存之间又加了一个高速缓存 器件,其相比计算机主内存的特点是:读写速度比内存快 10 以上,接近于 CPU 的速度,但是其储存空间很小。我们可以用一张图来看一下 CPU、高速缓存和内存之间的关系:
这里写图片描述
我们再从 Java 线程角度上来看 Java 的内存模型:
从 Java 线程角度,我们把 Java 内存模型分为主内存和每条线程私有的工作内存。也就是说,从这个角度上看,Java 内存模型就只剩下两个类型:主内存、线程工作内存。和计算机的内存模型类似,我们也可以通过一张图来理解下 Java 线程工作内存和主内存之间的关系:
这里写图片描述
从上图中我们可以看到:
1、Java 线程只能直接对其的私有工作内存进行读取和写入数据操作,而不能对主内存直接进行读取和写入操作。
2、主内存对所有的 Java 线程都可见,即所有的 Java 线程都可以通过其工作内存来间接的修改主内存中的数据。
3、线程的工作内存只对其对应的 Java 线程可见,不同的 Java 线程不共享其工作内存。

而在图中,线程私有工作内存和主内存之间又可以进行互相的读取和写入操作,然而这里的 “读取/写入” 操作的描述其实并不严谨,因为 Java 线程工作内存和主内存之间的交互需要遵循 Java 规定的交互协议,这个交互协议定义了 8 种原子性的操作来完成线程工作内存和主内存的交互,但是在这里我们并不需要去深入的了解这 8 中操作的原理,我们只需要知道这些概念并且知道线程私有的工作内存可以通过某些 Java 底层已经实现的操作来和主内存进行数据的交互就可以了。

现在我们知道,如果一个 Java 线程要修改主内存中的某个数据,它必须经过下面几个步骤:
1、这个线程的私有工作内存读取在主内存中要修改的那个数据值并且拷贝一份副本留在该线程的工作内存中;
2、线程执行相关代码在其工作内存中修改这个从主内存拷贝过来的副本值;
3、该线程的工作内存将修改后的值写入到主内存中。

假设现在在主内存中有一个 int 类型的变量 x 值为 10,如果我想通过线程将这个变量 x 得值改为 1,根据上面的描述,会经过哪些过程?来看一张图:
这里写图片描述

原子性

了解了线程角度上的 Java 的内存模型之后,我们再来看一下原子性的概念,我们很多情况下都可能听到 原子性 这个词。
在自然界中,原子是构成物质的基本单位(当然电子等暂且不论),所以原子的意思代表着——“不可分的最小单位”。
在操作系统中的定义是:对于一个操作来说,如果执行它,那么在执行过程中不会被其他因素打断直到完成这个操作,否则这个操作就不执行。我们称这个操作具有原子性。
我们在 Java 中常用的 a = 1; 操作通常是具有原子性的,而类似于 a += 1;a++; 等操作就不具有原子性。为什么会有这个结论呢?要深入理解这个问题,我们需要从它们的字节码入手,我们可以创建一个 Java 类 Test.java

public class Test {
	static int a;
	
	public static void decrease() {
		a--;
	}
	
	public static void setA(int t) {
		a = t;
	}
	
	public static int getA() {
		return a;
	}
}

我们用 javac 指令来编译这个类文件(注意:在使用这些 Java 指令之前,必须保证你的计算机已经将 JDK 中 bin 目录加入了环境变量,否则需要使用指令的绝对路径),具体格式为 javac 类文件的绝对路径 ,编译完成后我们在源文件的相同路径下会得到一个同名的 class 文件 test.class 。接下来我们再用 javap -v class文件的绝对路径 来得到对应的字节码,结果如下:
这里写图片描述
我们可以看到类中的方法中有 getstaticiconst_1isubputstaticireturnreturn 指令,有点类似于汇编指令。我们来看一下它们大概的意思:

getstatic 指令为从静态储存区取出变量的值并且压入操作栈顶
iconst_1 指令为将整形常量 1 压入操作栈顶
isub 指令为从栈中取出两个整形变量将相减的结果压入操作栈顶
putstatic 指令为从操作栈顶中取出变量的值并将变量值写入主内存中
iload_0 指令为将局部变量(这里即为 setA 方法的第一个参数)压入到操作栈顶
ireturn 指令为方法结束并返回从操作栈顶取出的 int 类型值
return 即为方法的结束返回指令

指令中提到的栈存在于 Java 线程私有的工作内存中。
我们可以看到在 setA 方法的字节码中对变量 a 进行改变的字节码只有 putstatic ,所以我们可以把它理解成原子性的。同样的,在 getA 方法中取出变量 a 的字节码也只有 ‘getstatic’ ,因此我们也可以把它理解成原子性的。但是对于 a--; 我们可以看到其操作的字节码是这么一段:

getstatic
iconst_1
isub
putstatic

很明显 a--; 转化成字节码后要进行多步操作,所以其在没有另加同步措施干预的情况下不具有原子性。对于 a += 1;a++; 等操作也是同样的道理,相信你也可以通过字节码来分析这些操作。

线程并发带来的问题

有了上面的知识之后,我们再来看一下我们平常经常遇到的多线程并发的奇怪问题:
1、卖车票问题:假设有 10 张火车票,现在有 5 个线程模拟 5 个窗口卖票,我们用 Java 代码模拟这个过程:

/**
 * 售卖火车票的测试类
 */
public static class SellTickets {
   
	static int tickets = 10; // 10 张火车票
	
	protected static void sell() {
   
		System.out.println(Thread.currentThread().getName() + "卖出了第 " + tickets-- + " 张票");
	}
	
	public static void startSell() {
   
		// 开启 5 个线程售票
		for (int i = 0; i < 5; i++) {
   
			new Thread("窗口" + (i+1)) 
  • 6
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 16
    评论
评论 16
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值