Java多线程0:核心理论
并发编程是Java程序员最重要的技能之一,也是最难掌握的一种技能。它要求编程者对计算机最底层的运作原理有深刻的理解,同时要求编程者逻辑清晰、思维缜密,这样才能写出高效、安全、可靠的多线程并发程序。本系列会从线程间协调的方式(wait、notify、notifyAll)、Synchronized及Volatile的本质入手,详细解释JDK为我们提供的每种并发工具和底层实现机制。在此基础上,我们会进一步分析java.util.concurrent包的工具类,包括其使用方式、实现源码及其背后的原理。本文是该系列的第一篇文章,是这系列中最核心的理论部分,之后的文章都会以此为基础来分析和解释。
一、共享性
数据共享是为什么要考虑线程安全的主要原因之一。如果所有的数据只是在当前线程内有效,那就不需要考虑线程安全问题。但是,在多线程编程中,数据共享是不可避免的。比如夫妻双方一人在柜台取钱,一人在ATM上取钱,两个取钱线程共享账户中的余额这一变量,这时候就要考虑线程安全问题了。
举例1:以银行取钱为例说明多线程之间的数据共享
定义一个账户Account,成员变量为账户编号和余额,以及一个取钱的方法,方法中对取钱金额做了判断,只有取的钱<=账户余额的时候,才能取钱成功。
public class Account { //账户编号 private int accountNo; //账户余额 private int balance; public Account(int accountNo, int balance) { this.accountNo = accountNo; this.balance = balance; } //取钱方法 public void drawMoney(int drawMoneyCount){ if(balance >= drawMoneyCount){ try { Thread.sleep(1000); System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款成功"); this.balance = balance - drawMoneyCount; System.out.println("账户余额为===" + (this.balance)); } catch (InterruptedException e) { e.printStackTrace(); } }else{ System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款失败,余额不足"); } } }
定义一个取钱线程类:
public class Thread03 extends Thread{ private Account account; private int drawMoneyCount; //初始化账户余额和取钱金额 public Thread03(int drawMoneyCount,Account account){ this.drawMoneyCount = drawMoneyCount; this.account = account; } @Override public void run() { account.drawMoney(drawMoneyCount); } }
测试,定义两个取钱线程
public class Test { public static void main(String[] args) { Account account = new Account(123456789,800); //取钱线程1 Thread thread1 = new Thread03(500,account); thread1.setName("张三"); //取钱线程2 Thread thread2 = new Thread03(500,account); thread2.setName("李四"); thread1.start(); thread2.start(); } }
结果:
张三取款金额为 = 500,取款成功 账户余额为===300 李四取款金额为 = 500,取款成功 账户余额为===-200
说明:可以看到,初始账户余额为800,张三取了500,账户余额还剩300,李四取500的时候,按说已经对取钱金额进行校验,不应该取钱成功,但李四还是取出了500。账户余额还剩-200。这是因为两个取钱的线程同时进入到Account的drawMoney方法内部,校验金额的时候都是800>=500,所以都能取钱成功。
解决方法就是对取钱方法进行同步,用synchronized修饰,确保一个取钱线程对共享变量账户余额操作时,另一个取钱线程处于阻塞状态。此处先看一下结果,后续会详细解释synchronized锁机制。
public class Account { //账户编号 private int accountNo; //账户余额 private int balance; public Account(int accountNo, int balance) { this.accountNo = accountNo; this.balance = balance; } //取钱方法 public synchronized void drawMoney(int drawMoneyCount){ if(balance >= drawMoneyCount){ try { Thread.sleep(1000); System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款成功"); this.balance = balance - drawMoneyCount; System.out.println("账户余额为===" + (this.balance)); } catch (InterruptedException e) { e.printStackTrace(); } }else{ System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款失败,余额不足"); } } }
其余都不变,看一下结果:
张三取款金额为 = 500,取款成功 账户余额为===300 李四取款金额为 = 500,取款失败,余额不足
所以,多线程情况下对共享变量的操作,要考虑线程安全问题。
举例2:以银行存钱、取钱为例说明多线程之间的数据共享
public class Account { //账户编号 private int accountNo; //账户余额 private int balance; public Account(int accountNo, int balance) { this.accountNo = accountNo; this.balance = balance; } //取钱方法 public void drawMoney(int drawMoneyCount){ if(balance >= drawMoneyCount){ System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款成功"); this.balance = balance - drawMoneyCount; System.out.println("账户余额为===" + (this.balance)); }else{ System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款失败,余额不足"); } } //存钱方法 public void depositMoney(int depositMoneyCount){ System.out.println(Thread.currentThread().getName() + "存钱成功,余额为 = " + (this.balance + depositMoneyCount)); this.balance = balance + depositMoneyCount; } }
定义一个取钱线程类
public class Thread03 extends Thread{ private Account account; private int drawMoneyCount; //初始化账户余额和取钱金额 public Thread03(int drawMoneyCount,Account account){ this.drawMoneyCount = drawMoneyCount; this.account = account; } @Override public void run() { account.drawMoney(drawMoneyCount); } }
定义一个存钱线程类
public class Thread04 extends Thread{ private Account account; private int depositMoneyCount; //初始化账户余额和存钱金额 public Thread04(int depositMoneyCount, Account account){ this.depositMoneyCount = depositMoneyCount; this.account = account; } @Override public void run() { account.depositMoney(depositMoneyCount); } }
测试,初始账户余额为0,进行存钱取钱。
public class Test { public static void main(String[] args) { Account account = new Account(123456789,0); //存钱线程 Thread thread1 = new Thread04(200,account); thread1.setName("张三"); //取钱线程 Thread thread2 = new Thread03(100,account); thread2.setName("李四"); thread1.start(); thread2.start(); } }
结果:(其中一种)
张三存钱成功,余额为 = 200
李四取款金额为 = 100,取款失败,余额不足
说明:可以看到,张三已经存了200块钱,余额已经是200,但是李四取100没有成功。说明李四存钱的时候,张三同时进行了取钱操作,因为两个线程操作的是同一个对象的变量(account对象的balance变量),所以,张三存钱还没结束的时候,李四取钱的时候余额还是0,所以会失败。
解决方法就是对存钱方法和取钱方法进行同步,用synchronized修饰,确保取钱时,存钱线程处于阻塞状态,存钱时,取钱线程处于阻塞状态,这样就不会发生两个线程同一时间对同一对象的变量进行操作。此处先看一下结果,后续会详细解释synchronized锁机制。
public class Account { //账户编号 private int accountNo; //账户余额 private int balance; public Account(int accountNo, int balance) { this.accountNo = accountNo; this.balance = balance; } //取钱方法 public synchronized void drawMoney(int drawMoneyCount){ if(balance >= drawMoneyCount){ System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款成功"); this.balance = balance - drawMoneyCount; System.out.println("账户余额为===" + (this.balance)); }else{ System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款失败,余额不足"); } } //存钱方法 public synchronized void depositMoney(int depositMoneyCount){ System.out.println(Thread.currentThread().getName() + "存钱成功,余额为 = " + (this.balance + depositMoneyCount)); this.balance = balance + depositMoneyCount; } }
其余不变,看一下结果:
张三存钱成功,余额为 = 200 李四取款金额为 = 100,取款成功 账户余额为===100
所以,多线程情况下对共享变量的操作,要考虑线程安全问题。
二、互斥性
资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。我们通常允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作。所以我们通常将锁分为共享锁和排它锁,也叫做读锁和写锁。
如果资源不具有互斥性,即使是共享资源,我们也不需要担心线程安全。例如,对于不可变的数据共享,所有线程都只能对其进行读操作,所以不用考虑线程安全问题。但是对共享数据的写操作,一般就需要保证互斥性,上述例子中就是因为没有保证互斥性才导致存钱取钱出现问题。Java 中提供多种机制来保证互斥性,最简单的方式是使用Synchronized,参见上述加了synchronized的结果。
三、原子性
原子性就是指对数据的操作是一个独立的、不可分割的整体。换句话说,就是一次操作,是一个连续不可中断的过程,数据不会执行的一半的时候被其他线程所修改。保证原子性的最简单方式是操作系统指令,就是说如果一次操作对应一条操作系统指令,这样肯定可以能保证原子性。但是很多操作不能通过一条指令就完成。例如,对long类型的运算,很多系统就需要分成多条指令分别对高位和低位进行操作才能完成。还比如,我们经常使用的整数 i++ 的操作,其实需要分成三个步骤:(1)读取整数 i 的值;(2)对 i 进行加一操作;(3)将结果写回内存。这个过程在多线程下就可能出现如下现象:
这也是代码段一执行的结果为什么不正确的原因。对于这种组合操作,要保证原子性,最常见的方式是加锁,如Java中的Synchronized或Lock都可以实现,代码段二就是通过Synchronized实现的。除了锁以外,还有一种方式就是CAS(Compare And Swap),即修改数据之前先比较与之前读取到的值是否一致,如果一致,则进行修改,如果不一致则重新执行,这也是乐观锁的实现原理。不过CAS在某些场景下不一定有效,比如另一线程先修改了某个值,然后再改回原来值,这种情况下,CAS是无法判断的。
四、可见性
要理解可见性,需要先对JVM的内存模型有一定的了解,JVM的内存模型与操作系统类似,如图所示:
从这个图中我们可以看出,每个线程都有一个自己的工作内存(相当于CPU高级缓冲区,这么做的目的还是在于进一步缩小存储系统与CPU之间速度的差异,提高性能),对于共享变量,线程每次读取的是工作内存中共享变量的副本,写入的时候也直接修改工作内存中副本的值,然后在某个时间点上再将工作内存与主内存中的值进行同步。这样导致的问题是,如果线程1对某个变量进行了修改,线程2却有可能看不到线程1对共享变量所做的修改。
举例:
public class Thread01 extends Thread{ private boolean runningFlag = true; public void setRunningFlag(boolean runningFlag) { this.runningFlag = runningFlag; System.out.println(Thread.currentThread().getName() + "线程set runningFlag = " + runningFlag); } public boolean isRunningFlag() { return runningFlag; } @Override public void run() { System.out.println(Thread.currentThread().getName() + "线程进入run方法,runningFlag = " + runningFlag); while(runningFlag){ } System.out.println("run方法执行完成,线程停止"); } }
测试
public class Test { public static void main(String[] args) { Thread01 thread = new Thread01(); thread.start(); try { Thread.sleep(1000); thread.setRunningFlag(false); } catch (InterruptedException e) { e.printStackTrace(); } } }
结果:
说明:可以看到,runningFlag已经被设置为false,但是Thread-0线程还是没有停止。出现打印结果现象的原因就是主内存和工作内存中数据的不同步造成的。因为Thread-0执行run()方法的时候拿到一个主内存runningFlag的拷贝,而设置runningFlag是在main线程中做的,换句话说 ,设置的runningFlag设置的是主内存中的runningFlag,更新了主内存的runningFlag,线程Thread-0工作内存中的runningFlag没有更新,还是true,当然一直死循环了。
volatile的作用就是这样,被volatile修饰的变量,保证了每次读取到的都是最新的那个值。线程安全围绕的是可见性和原子性这两个特性展开的,volatile解决的是变量在多个线程之间的可见性,但是无法保证原子性。
多提一句,synchronized除了保障了原子性外,其实也保障了可见性。因为synchronized无论是同步的方法还是同步的代码块,都会先把主内存的数据拷贝到工作内存中,同步代码块结束,会把工作内存中的数据更新到主内存中,这样主内存中的数据一定是最新的。
解决这个问题很简单,用volatile修饰runningFlag即可,加上了volatile的意思是,线程Thread-0每次读取runningFlag的值的时候,都先从主内存中把runningFlag同步到线程的工作内存中,再获取当前时刻最新的runningFlag。看一下给runningFlag加了volatile关键字的运行效果:
public class Thread01 extends Thread{ private volatile boolean runningFlag = true; public void setRunningFlag(boolean runningFlag) { this.runningFlag = runningFlag; System.out.println(Thread.currentThread().getName() + "线程set runningFlag = " + runningFlag); } public boolean isRunningFlag() { return runningFlag; } @Override public void run() { System.out.println(Thread.currentThread().getName() + "线程进入run方法,runningFlag = " + runningFlag); while(runningFlag){ } System.out.println("run方法执行完成,线程停止"); } }
结果:
五、有序性
为了提高性能,编译器和处理器可能会对指令做重排序。重排序可以分为三种:
(1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
(2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
(3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
我们可以直接参考一下JSR 133 中对重排序问题的描述:
(1) (2)
先看上图中的(1)源码部分,从源码来看,要么指令 1 先执行要么指令 3先执行。如果指令 1 先执行,r2不应该能看到指令 4 中写入的值。如果指令 3 先执行,r1不应该能看到指令 2 写的值。但是运行结果却可能出现r2==2,r1==1的情况,这就是“重排序”导致的结果。上图(2)即是一种可能出现的合法的编译结果,编译后,指令1和指令2的顺序可能就互换了。因此,才会出现r2==2,r1==1的结果。Java 中也可通过Synchronized或Volatile来保证顺序性。
六 总结
本文对Java 并发编程中的理论基础进行了讲解,有些东西在后续的分析中还会做更详细的讨论,如可见性、顺序性等。后续的文章都会以本章内容作为理论基础来讨论。如果大家能够很好的理解上述内容,相信无论是去理解其他并发编程的文章还是在平时的并发编程的工作中,都能够对大家有很好的帮助。
参考资料:
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】博客园携手 AI 驱动开发工具商 Chat2DB 推出联合终身会员
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· ASP.NET Core - 日志记录系统(二)
· .NET 依赖注入中的 Captive Dependency
· .NET Core 对象分配(Alloc)底层原理浅谈
· 聊一聊 C#异步 任务延续的三种底层玩法
· 敏捷开发:如何高效开每日站会
· 互联网不景气了那就玩玩嵌入式吧,用纯.NET开发并制作一个智能桌面机器人(一):从.NET IoT入
· .NET 开发的分流抢票软件,不做广告、不收集隐私
· ASP.NET Core - 日志记录系统(二)
· C#实现 Winform 程序在系统托盘显示图标 & 开机自启动
· 实现windows下简单的自动化窗口管理