最新发布《数智时代的AI人才粮仓模型解读白皮书(2024版)》,立即领取! 了解详情
写点什么

Java 深度历险(三)——Java 线程​:基本概念、可见性与同步

  • 2011-01-17
  • 本文字数:4697 字

    阅读完需:约 15 分钟

开发高性能并发应用不是一件容易的事情。这类应用的例子包括高性能 Web 服务器、游戏服务器和搜索引擎爬虫等。这样的应用可能需要同时处理成千上万个请求。对于这样的应用,一般采用多线程或事件驱动的架构。对于Java 来说,在语言内部提供了线程的支持。但是Java 的多线程应用开发会遇到很多问题。首先是很难编写正确,其次是很难测试是否正确,最后是出现问题时很难调试。一个多线程应用可能运行了好几天都没问题,然后突然就出现了问题,之后却又无法再次重现出来。如果在正确性之外,还需要考虑应用的吞吐量和性能优化的话,就会更加复杂。本文主要介绍Java 中的线程的基本概念、可见性和线程同步相关的内容。

Java 线程基本概念

在操作系统中两个比较容易混淆的概念是进程(process)和线程(thread)。操作系统中的进程是资源的组织单位。进程有一个包含了程序内容和数据的地址空间,以及其它的资源,包括打开的文件、子进程和信号处理器等。不同进程的地址空间是互相隔离的。而线程表示的是程序的执行流程,是CPU 调度的基本单位。线程有自己的程序计数器、寄存器、栈和帧等。引入线程的动机在于操作系统中阻塞式I/O 的存在。当一个线程所执行的I/O 被阻塞的时候,同一进程中的其它线程可以使用CPU 来进行计算。这样的话,就提高了应用的执行效率。线程的概念在主流的操作系统和编程语言中都得到了支持。

一部分的Java 程序是单线程的。程序的机器指令按照程序中给定的顺序依次执行。Java 语言提供了 java.lang.Thread 类来为线程提供抽象。有两种方式创建一个新的线程:一种是继承 java.lang.Thread 类并覆写其中的 run() 方法,另外一种则是在创建 java.lang.Thread 类的对象的时候,在构造函数中提供一个实现了 java.lang.Runnable 接口的类的对象。在得到了 java.lang.Thread 类的对象之后,通过调用其 start() 方法就可以启动这个线程的执行。

一个线程被创建成功并启动之后,可以处在不同的状态中。这个线程可能正在占用 CPU 时间运行;也可能处在就绪状态,等待被调度执行;还可能阻塞在某个资源或是事件上。多个就绪状态的线程会竞争 CPU 时间以获得被执行的机会,而 CPU 则采用某种算法来调度线程的执行。不同线程的运行顺序是不确定的,多线程程序中的逻辑不能依赖于 CPU 的调度算法。

可见性

可见性(visibility)的问题是 Java 多线程应用中的错误的根源。在一个单线程程序中,如果首先改变一个变量的值,再读取该变量的值的时候,所读取到的值就是上次写操作写入的值。也就是说前面操作的结果对后面的操作是肯定可见的。但是在多线程程序中,如果不使用一定的同步机制,就不能保证一个线程所写入的值对另外一个线程是可见的。造成这种情况的原因可能有下面几个:

  • CPU 内部的缓存:现在的 CPU 一般都拥有层次结构的几级缓存。CPU 直接操作的是缓存中的数据,并在需要的时候把缓存中的数据与主存进行同步。因此在某些时刻,缓存中的数据与主存内的数据可能是不一致的。某个线程所执行的写入操作的新值可能当前还保存在 CPU 的缓存中,还没有被写回到主存中。这个时候,另外一个线程的读取操作读取的就还是主存中的旧值。
  • CPU 的指令执行顺序:在某些时候,CPU 可能改变指令的执行顺序。这有可能导致一个线程过早的看到另外一个线程的写入操作完成之后的新值。
  • 编译器代码重排:出于性能优化的目的,编译器可能在编译的时候对生成的目标代码进行重新排列。

现实的情况是:不同的 CPU 可能采用不同的架构,而这样的问题在多核处理器和多处理器系统中变得尤其复杂。而 Java 的目标是要实现“编写一次,到处运行”,因此就有必要对 Java 程序访问和操作主存的方式做出规范,以保证同样的程序在不同的 CPU 架构上的运行结果是一致的。Java 内存模型( Java Memory Model )就是为了这个目的而引入的。 JSR 133 则进一步修正了之前的内存模型中存在的问题。总得来说,Java 内存模型描述了程序中共享变量的关系以及在主存中写入和读取这些变量值的底层细节。Java 内存模型定义了 Java 语言中的 synchronized volatile final 等关键词对主存中变量读写操作的意义。Java 开发人员使用这些关键词来描述程序所期望的行为,而编译器和 JVM 负责保证生成的代码在运行时刻的行为符合内存模型的描述。比如对声明为 volatile 的变量来说,在读取之前,JVM 会确保 CPU 中缓存的值首先会失效,重新从主存中进行读取;而写入之后,新的值会被马上写入到主存中。而 synchronized 和 volatile 关键词也会对编译器优化时候的代码重排带来额外的限制。比如编译器不能把 synchronized 块中的代码移出来。对 volatile 变量的读写操作是不能与其它读写操作一块重新排列的。

Java 内存模型中一个重要的概念是定义了“在之前发生(happens-before)”的顺序。如果一个动作按照“在之前发生”的顺序发生在另外一个动作之前,那么前一个动作的结果在多线程的情况下对于后一个动作就是肯定可见的。最常见的“在之前发生”的顺序包括:对一个对象上的监视器的解锁操作肯定发生在下一个对同一个监视器的加锁操作之前;对声明为 volatile 的变量的写操作肯定发生在后续的读操作之前。有了“在之前发生”顺序,多线程程序在运行时刻的行为在关键部分上就是可预测的了。编译器和 JVM 会确保“在之前发生”顺序可以得到保证。比如下面的一个简单的方法:

复制代码
public void increase() {
this.count++;
}

这是一个常见的计数器递增方法,this.count++ 实际是 this.count = this.count + 1,由一个对变量 this.count 的读取操作和写入操作组成。如果在多线程情况下,两个线程执行这两个操作的顺序是不可预期的。如果 this.count 的初始值是 1,两个线程可能都读到了为 1 的值,然后先后把 this.count 的值设为 2,从而产生错误。错误的原因在于其中一个线程对 this.count 的写入操作对另外一个线程是不可见的,另外一个线程不知道 this.count 的值已经发生了变化。如果在 increase() 方法声明中加上 synchronized 关键词,那就在两个线程的操作之间强制定义了一个“在之前发生”顺序。一个线程需要首先获得当前对象上的锁才能执行,在它拥有锁的这段时间完成对 this.count 的写入操作。而另一个线程只有在当前线程释放了锁之后才能执行。这样的话,就保证了两个线程对 increase() 方法的调用只能依次完成,保证了线程之间操作上的可见性。

如果一个变量的值可能被多个线程读取,又能被最少一个线程锁写入,同时这些读写操作之间并没有定义好的“在之前发生”的顺序的话,那么在这个变量上就存在数据竞争(data race)。数据竞争的存在是 Java 多线程应用中要解决的首要问题。解决的办法就是通过 synchronized 和 volatile 关键词来定义好“在之前发生”顺序。

Java 中的锁

当数据竞争存在的时候,最简单的解决办法就是加锁。锁机制限制在同一时间只允许一个线程访问产生竞争的数据的临界区。Java 语言中的 synchronized 关键字可以为一个代码块或是方法进行加锁。任何 Java 对象都有一个自己的监视器,可以进行加锁和解锁操作。当受到 synchronized 关键字保护的代码块或方法被执行的时候,就说明当前线程已经成功的获取了对象的监视器上的锁。当代码块或是方法正常执行完成或是发生异常退出的时候,当前线程所获取的锁会被自动释放。一个线程可以在一个 Java 对象上加多次锁。同时 JVM 保证了在获取锁之前和释放锁之后,变量的值是与主存中的内容同步的。

Java 线程的同步

在有些情况下,仅依靠线程之间对数据的互斥访问是不够的。有些线程之间存在协作关系,需要按照一定的协议来协同完成某项任务,比如典型的生产者 - 消费者模式。这种情况下就需要用到 Java 提供的线程之间的等待 - 通知机制。当线程所要求的条件不满足时,就进入等待状态;而另外的线程则负责在合适的时机发出通知来唤醒等待中的线程。Java 中的 java.lang.Object 类中的 wait / notify / notifyAll 方法组就是完成线程之间的同步的。

在某个 Java 对象上面调用 wait 方法的时候,首先要检查当前线程是否获取到了这个对象上的锁。如果没有的话,就会直接抛出 java.lang.IllegalMonitorStateException 异常。如果有锁的话,就把当前线程添加到对象的等待集合中,并释放其所拥有的锁。当前线程被阻塞,无法继续执行,直到被从对象的等待集合中移除。引起某个线程从对象的等待集合中移除的原因有很多:对象上的 notify 方法被调用时,该线程被选中;对象上的 notifyAll 方法被调用;线程被中断;对于有超时限制的 wait 操作,当超过时间限制时;JVM 内部实现在非正常情况下的操作。

从上面的说明中,可以得到几条结论:wait/notify/notifyAll 操作需要放在 synchronized 代码块或方法中,这样才能保证在执行 wait/notify/notifyAll 的时候,当前线程已经获得了所需要的锁。当对于某个对象的等待集合中的线程数目没有把握的时候,最好使用 notifyAll 而不是 notify。notifyAll 虽然会导致线程在没有必要的情况下被唤醒而产生性能影响,但是在使用上更加简单一些。由于线程可能在非正常情况下被意外唤醒,一般需要把 wait 操作放在一个循环中,并检查所要求的逻辑条件是否满足。典型的使用模式如下所示:

复制代码
private Object lock = new Object();
synchronized (lock) {
while (/* 逻辑条件不满足的时候 */) {
try {
lock.wait();
} catch (InterruptedException e) {}
}
// 处理逻辑
}

上述代码中使用了一个私有对象 lock 来作为加锁的对象,其好处是可以避免其它代码错误的使用这个对象。

中断线程

通过一个线程对象的 interrupt() 方法可以向该线程发出一个中断请求。中断请求是一种线程之间的协作方式。当线程 A 通过调用线程 B 的 interrupt() 方法来发出中断请求的时候,线程 A 是在请求线程 B 的注意。线程 B 应该在方便的时候来处理这个中断请求,当然这不是必须的。当中断发生的时候,线程对象中会有一个标记来记录当前的中断状态。通过 isInterrupted() 方法可以判断是否有中断请求发生。如果当中断请求发生的时候,线程正处于阻塞状态,那么这个中断请求会导致该线程退出阻塞状态。可能造成线程处于阻塞状态的情况有:当线程通过调用 wait() 方法进入一个对象的等待集合中,或是通过 sleep() 方法来暂时休眠,或是通过 join() 方法来等待另外一个线程完成的时候。在线程阻塞的情况下,当中断发生的时候,会抛出 java.lang.InterruptedException ,代码会进入相应的异常处理逻辑之中。实际上在调用 wait/sleep/join 方法的时候,是必须捕获这个异常的。中断一个正在某个对象的等待集合中的线程,会使得这个线程从等待集合中被移除,使得它可以在再次获得锁之后,继续执行 java.lang.InterruptedException 异常的处理逻辑。

通过中断线程可以实现可取消的任务。在任务的执行过程中可以定期检查当前线程的中断标记,如果线程收到了中断请求,那么就可以终止这个任务的执行。当遇到 java.lang.InterruptedException 的异常,不要捕获了之后不做任何处理。如果不想在这个层次上处理这个异常,就把异常重新抛出。当一个在阻塞状态的线程被中断并且抛出 java.lang.InterruptedException 异常的时候,其对象中的中断状态标记会被清空。如果捕获了 java.lang.InterruptedException 异常但是又不能重新抛出的话,需要通过再次调用 interrupt() 方法来重新设置这个标记。

参考资料


感谢张凯峰对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

2011-01-17 18:3722096

评论

发布
暂无评论
发现更多内容

企业级软件的核心价值

Philips

敏捷开发 企业应用

LeetCode题解:剑指 Offer 22. 链表中倒数第k个节点,双指针,JavaScript,详细注释

Lee Chen

算法 大前端 LeetCode

《迅雷链精品课》第三课:区块链主流框架分析

迅雷链

区块链 区块链方案 区块链+ 区块链应用

mongodb 源码实现系列 - 网络传输层模块实现四

杨亚洲(专注MongoDB及高性能中间件)

MySQL 数据库 mongodb 高性能 分布式数据库mongodb

【Mycat】作为Mycat核心开发者,怎能不来一波Mycat系列文章?

冰河

分布式事务 分布式数据库 系统架构 分布式存储 mycat

我就是增发、健身、养猫、社交通通拥有的锦鲤本鲤

脑极体

轻松云上揽胜中华,靠的就是这份聪明的“地图”!

华为云开发者联盟

MySQL 数据库 postgresql AI 地图

云图说|多模态AI开发套件HiLens Kit:超强算力彰显云上实力

华为云开发者联盟

人工智能 开发者 物联网 机器人 华为云

亲测三遍!8步搭建一个属于自己的网站

华为云开发者联盟

MySQL Linux 开发者 网站 华为云

LAXCUS 大数据集群操作系统:一个分布式分时共享 E 级系统软件(六)

陈泽云

人工智能 大数据 算法

mPaaS 客户端问题排查之漫长的 3s 等待之谜

阿里云金融线TAM SRE专家服务团队

mPaaS

微众银行大数据平台建设方案

康月牙

大数据 开源 金融 平台 微众银行

【涂鸦物联网足迹】涂鸦云平台接口列表

IoT云工坊

人工智能 接口 物联网 API 智能家居

C++多元组tuple使用方法?你熟悉吗?快来看看吧

良知犹存

c++

什么?还不懂c++vector的用法,你凭什么勇气来的!

良知犹存

c++

阿里大牛说:你凭什么搞不懂SpringBoot,Cloud,Nginx与Docker

小Q

Java 学习 编程 架构 面试

Github标星67.9k的微服务架构以及架构设计模式笔记我真的爱了

Java架构之路

Java 程序员 架构 面试 编程语言

“双11”购物狂欢节,所有女生走进了谁的直播间?

博睿数据

APM AIOPS 拨测 直播 用户体验

高交会科技盛宴:“科技改变生活,创新驱动发展”

13530558032

WE大会上,科学家们是怎样治愈“小破球”的?

脑极体

面试,到底在考察什么?

程序员架构进阶

面试 方法论

java-File对象

Isuodut

IMC总决赛精彩对战应接不暇,英特尔酷睿极致性能燃爆比赛现场!

E科讯

为什么我就面试阿里P6,好不容易过2面,3面来个架构师来吊打我?

小Q

Java 学习 程序员 架构 面试

多线程并发主题-ThreadLocalRandom类

公众号:程序猿成神之路

Java 并发编程 线程

Teambition 网盘 VS 阿里云盘:阿里这个浓眉大眼的也开始玩赛马了?

郭旭东

阿里云 阿里云网盘

手把手教你本地 k8s 集群搭建云原生 Tekton CICD 流水线

比伯

Java 大数据 编程 架构 计算机

握草!美团P8整理的280页超详细Docker实战文档简直太香了,让你对如日中天的Docker有更深入的了解。

Java架构之路

Java 程序员 架构 面试 编程语言

DeFi质押挖矿系统开发技术

薇電13242772558

区块链 defi

LAXCUS 大数据集群操作系统:一个分布式分时共享 E 级系统软件(七)

陈泽云

人工智能 大数据 算法

利用下班时间,我两星期完成了redis入门与进阶

小松漫步

数据库 redis

Java深度历险(三)——Java线程​:基本概念、可见性与同步_Java_成富_InfoQ精选文章