写点什么

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:3722280

评论

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

基于ECS快速搭建 Docker 环境

若尘

Docker 服务器 8月日更

多语言ASR?没有什么听不懂,15种语言我全都要

华为云开发者联盟

语言 ASR 多语言ASR 单语模型 Conformer

复杂多变场景下的Groovy脚本引擎实战

vivo互联网技术

敏捷开发 脚本语言

Druid 从控制台(Druid console)从 SQL 脚本转换为 JSON 格式的方法

HoneyMoose

netty系列之:netty初探

程序那些事

Java 响应式编程 Netty nio 程序那些事

【Maven技术专题】如何使用Assembly插件实现自定义打包

洛神灬殇

maven assembly 8月日更

深度学习中的分布式训练

安第斯智能云

人工智能 深度学习

原来select语句在MySQL中是这样执行的!看完又涨见识了!这回我要碾压面试官!

冰河

MySQL 面试 精通MySQL SELECT查询流程 查询缓存

分布式性能测试框架用例方案设想(二)

FunTester

分布式 性能测试 接口测试 测试框架 测试开发

重磅 | 用友《数字化中台》震撼上市!数智化转型和商业创新实践的企业级经验!

博文视点Broadview

百度爱番番移动端网页秒开实践

百度Geek说

大前端 优化 网页加速 移动端

鸿蒙内核之内存调测:动态内存池信息统计

华为云开发者联盟

鸿蒙 内存 动态内存池 内存信息

Springboot 配置文件、隐私数据脱敏的最佳实践(原理+源码)

程序员小富

Java springboot 数据安全 数据脱敏

Druid 从控制台(Druid console)中删除过滤器和运行查询

HoneyMoose

docker入门:postgresql安装及可视化界面portainer使用

小鲍侃java

8月日更

十大排序算法--插入排序

Ayue、

排序算法 8月日更

synchronized 优化手段之锁膨胀机制!

王磊

Java 并发 8月日更

PostgreSQL 中如何控制行级安全和列级安全

Qunar技术沙龙

sql postgresql 运维 安全 权限

Rust从0到1-模式-可反驳性

rust 模式 Patterns Refutability 可反驳性

Python代码阅读(第1篇):列表映射后的平均值

Felix

Python 编程 Code Programing 阅读代码

一文带你搞定AOP切面

华为云开发者联盟

spring aop 切面编程 面向切面编程 切面

Python OpenCV 图像区域轮廓标记,可用于框选各种小纸条

梦想橡皮擦

8月日更

【Flutter 专题】132 图解 PaginatedDataTable 分页表格

阿策小和尚

Flutter 小菜 0 基础学习 Flutter Android 小菜鸟 8月日更

Design for failure常见的12种设计思想

架构精进之路

降级 重试 容错 8月日更

Druid 的几个查询实例

HoneyMoose

索信达控股:银行4.0的AI世界——开启算法力的时代

索信达控股

人工智能 金融科技 银行

手撸二叉树之对称二叉树

HelloWorld杰少

算法和数据结构 8月日更

【设计模式】原型

Andy阿辉

C# 编程 后端 设计模式 8月日更

kafka SpringBoot

Rubble

kafka springboot 8月日更

Druid 通过 dsql 运行的时候提示错误 urllib2

HoneyMoose

基于docker的分布式性能测试框架功能验证(二)

FunTester

分布式 性能测试 接口测试 测试框架 测试开发

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