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 并发编程中的理论基础进行了讲解,有些东西在后续的分析中还会做更详细的讨论,如可见性、顺序性等。后续的文章都会以本章内容作为理论基础来讨论。如果大家能够很好的理解上述内容,相信无论是去理解其他并发编程的文章还是在平时的并发编程的工作中,都能够对大家有很好的帮助。
参考资料:
Java多线程0:核心理论的更多相关文章
- Java多线程编程核心(1)
Java多线程编程核心(1) 停止线程 本节主要讨论如何更好停止一个线程.停止线程意味着在线程处理完成任务之前放弃当前操作. 1.停不了的线程 可能大多数同学会使用interrupt()来停止线程,但 ...
- (1)Java多线程编程核心——Java多线程技能
1.为什么要使用多线程?多线程的优点? 提高CPU的利用率 2.什么是多线程? 3.Java实现多线程编程的两种方式? a.继承Thread类 public class MyThread01 exte ...
- Java多线程编程核心 - 对象及变量的并发访问
1.什么是“线程安全”与“非线程安全”? “非线程安全”会在多个线程对同一对象总的实例变量进行并发访问时发生,产生的后果是“脏读”,也就是取到的数据其实是被更改过的. “线程安全”是以获得的实例变量的 ...
- java多线程技术核心
1.进程的三大特征: 独立性:拥有自己的独立的地址空间,一个进程不可以直接去访问其他进程的地址空间. 动态性:是一个系统中活动的指令的集合. 并发性:单个进程可以在多个处理器上并发进行,互不影响. 2 ...
- Java多线程编程——进阶篇二
一.线程的交互 a.线程交互的基础知识 线程交互知识点需要从java.lang.Object的类的三个方法来学习: void notify() 唤醒在此对象监视器上等待的单个 ...
- Java多线程详解
Java线程:概念与原理 一.操作系统中线程和进程的概念 现在的操作系统是多任务操作系统.多线程是实现多任务的一种方式. 进程是指一个内存中运行的应用程序,每个进程都有自己独立的一块内存空间,一个进程 ...
- Java多线程 2 线程的生命周期和状态控制
一.线程的生命周期 线程状态转换图: 1.新建状态 用new关键字和Thread类或其子类建立一个线程对象后,该线程对象就处于新生状态.处于新生状态的线程有自己的内存空间,通过调用start方法进入就 ...
- Java多线程-线程的调度(休眠)
Java线程调度是Java多线程的核心,只有良好的调度,才能充分发挥系统的性能,提高程序的执行效率. 这里要明确的一点,不管程序员怎么编写调度,只能最大限度的影响线程执行的次序,而不能做到精准控制. ...
- Java多线程——线程的生命周期和状态控制
一.线程的生命周期 线程状态转换图: 1.新建状态 用new关键字和Thread类或其子类建立一个线程对象后,该线程对象就处于新生状态.处于新生状态的线程有自己的内存空间,通过调用start方法进入就 ...
随机推荐
- 004_Python之all()\any()
http://www.jianshu.com/p/65b6b4a62071 一.待验证整理
- 004_浅析Python的GIL和线程安全
在这里我们将介绍Python的GIL和线程安全,希望大家能从中理解Python里的GIL,以及GIL的前世今生. 对于Python的GIL和线程安全很多人不是很了解,通过本文,希望能让大家对Pytho ...
- SpringMVC配置多视图-内容协商原理
SpringMVC配置多视图-内容协商原理 2014年03月06日 16:46:59 日积月累_滴水石穿 阅读数:10964更多 个人分类: SpringMVC Spring Framework ...
- day26 Python 改变对象的字符串显示
__str__,__repr__,__format__ format_dict={ 'nat':'{obj.name}-{obj.addr}-{obj.type}',#学校名-学校地址-学校类型 't ...
- 20175310 《Java程序设计》第7周学习总结
20175310 <Java程序设计>第7周学习总结 本周博客: https://www.cnblogs.com/xicyannn/p/10705376.html 教材学习内容总结 这周学 ...
- Web组件流畅拖动效果
拖动效果,可以形象的帮助用户处理一些问题,比如Windows删除文件,只需将文件拖动至回收站即可.比起右键显得更形象,我觉得更好玩一点^_^.当然,在其他许多方面,其实也有用到拖动效果,只是他们不是那 ...
- c# 设置IE浏览器版本运行程序-设置webBrowser对应的IE内核版本来运行
//通常情况下,我们直接调用C#的webBrowser控件,默认的浏览器内核是IE7. 那么如何修改控件调用的默认浏览器版本呢?using System; using System.Collecti ...
- 【转】强化学习(一)Deep Q-Network
原文地址:https://www.hhyz.me/2018/08/05/2018-08-05-RL/ 1. 前言 虽然将深度学习和增强学习结合的想法在几年前就有人尝试,但真正成功的开端就是DeepMi ...
- Java多线程学习(四)---控制线程
控制线程 摘要: Java的线程支持提供了一些便捷的工具方法,通过这些便捷的工具方法可以很好地控制线程的执行 1. join线程控制,让一个线程等待另一个线程完成的方法 2. 后台线程,又称为守护线程 ...
- .NET下日志系统的搭建——log4net+kafka+elk
.NET下日志系统的搭建--log4net+kafka+elk 前言 我们公司的程序日志之前都是采用log4net记录文件日志的方式(有关log4net的简单使用可以看我另一篇博客),但是随着 ...