1. 同步问题
1.1 线程间的通信
管道流可以连接两个线程间的通信。
1.2 线程间的资源互斥共享
通常,一些同时运行的线程需要共享数据。在这种时候,每个线程就必须考虑与它一起共享数据的其他线程的状态与行为,否则就不能保证共享数据的一致性,因而也不能保证程序的正确性。
在 Java 中,通过提供一个特殊的锁定标志来处理数据。
1.3 对象的锁定标志
在 Java 语言中,引入了 “对象互斥锁” 的概念(又称为监视器、管程)来实现不同线程对共享数据操作的同步。“对象互斥锁” 阻止了多个线程同时访问同一条件变量。Java 可以为每一个对象的实例配有一个 “对象互斥锁”。
在 Java 语言中,有两种方法可以实现 “对象互斥锁”:
-
用关键字 volatile 声明一个共享数据(变量)。
-
用关键字 synchronized 声明操作共享数据的一个方法或一段代码。
因为等待一个 对象的锁定标志 的线程 要等到 持有该标志的线程 将其 返还后 才能继续运行,所以在 不使用该标志时 将其返还 就显得十分重要了。
事实上,当持有锁定标志的线程运行完 synchronized() 调用包含的程序块后,这个标志会被自动返还。
Java 保证了该标志通常能够被正确地返还,即使被同步的程序块产生了异常,或者某个循环中断跳出了该程序块,这个标志也能被正确返还。
同样,如果一个线程两次调用了同一个对象,在退出最外层后,这个标志也将被正确释放,而在退出内层时则不会执行释放。
1.3.1 volatile 关键字
volatile 三大特性:
- 可见性
volatile 关键字的使用主要是为了同步 在公共堆栈中的变量 和 在线程私有堆栈中的变量。主要通过当线程访问该变量时,强制其从公共堆栈中取值。
- 原子性
在 X86 架构64位JDK 版本中,写 double 或 long 是原子的。
- 进制代码重排序
在 Java 程序运行时,JIT(Just-In-Time Compiler,即时编译器)可以动态地改变程序代码运行的顺序,例如,有如下代码:
A代码 - 重耗时 B代码 - 轻耗时 C代码 - 重耗时 D代码 - 轻耗时
在多线程的环境中,JIT 有可能进行代码重排,重排序后的代码顺序可能如下:
B代码 - 轻耗时 D代码 - 轻耗时 A代码 - 重耗时 C代码 - 重耗时
这样做的主要原因是 CPU 流水线是同时执行这 4 个指令的,那么轻耗时的代码在很大程度上先执行完,以让出 CPU 流水线资源给其他指令,所以代码重排序是为了追求更高的程序运行效率。
重排序发生在没有依赖关系时,若上述代码中存在依赖关系,则不会进行重排序。
使用 volatile 可以禁止代码重排序,例如有如下代码:
A 变量操作
B 变量操作
volatile Z 变量操作
C 变量操作
D 变量操作
那么有 4 种情况发生:
1)A、B 可以重排序。
2)C、D 可以重排序。
3)A、B 不可以重排到 Z 的后面。
4)C、D 不可以重排到 Z 的前面。
换言之,变量 Z 是一个 “屏障”,Z 变量之前或之后的代码不可以跨越 Z 变量,这就是屏障的作用。
以上所述三种特性,都可以使用 synchronized 实现。
1.3.2 总结
关键字 volatile 的主要作用就是让其他线程可以看到最新的值,volatile 只能修饰变量。
使用场景:
当想实现一个变量的值被更改时,让其他线程能够获取到最新的值时,就要对变量使用 volatile。
1.4 同步方法
用 sychronized 标识的代码段或方法即为 “对象互斥锁” 锁住的部分。如果一个程序内有两个或以上的方法使用 sychronized 标志,则它们在同一个 “对象互斥锁” 管理之下。
一般情况下,多使用 synchronized 关键字在方法的层次上实现对共享资源操作的同步,很少使用 volatile 关键字声明共享变量。
同步 synchronized 在字节码指令中的原理
在方法中使用 synchronized 关键字实现同步的原因是使用了 flag 标记 ACC_SYNCHRONIZED,当调用方法时,调用指令会检查方法的 ACC_SYNCHRONIZED 访问标志是否设置,如果设置了,执行线程会先持有同步锁,然后执行方法,最后在方法完成时释放锁。
sychronized() 语句的标准写法为
public void push(char c) {
sychronized(this) {
...
}
}
由于 synchronzied() 语句的参数必须是 this ,因此 Java 语言允许下面的简便写法(不建议):
public sychronized void push(char c) {
...
}
比较以上两种写法,后者使用 sychronized 将整个方法视为同步块,这会使得持有锁定标记的时间比实际需要的要长,从而降低了效率。另一方面,使用前者来标记可以提醒用户同步在发生。这对于下面的 死锁 非常重要。
1.4.1 同步写法案例比较
使用关键字 synchronized 的写法比较多,常用的有如下几种:
public class MyService {
synchronized public static void testMethod1() {
}
public void testMethod2() {
synchronized (MyService.class) {
}
}
synchronized public void testMethod3() {
}
public void testMethod4() {
synchronized (this) {
}
}
public void testMethod5() {
synchronized ("abc") {
}
}
}
上面的代码出现了 3 种类型的锁对象:
(A)testMethod1() 和 testMethod2() 持有的锁是同一个,即 MyService.java 对应 Class 类的对象。
(B)testMethod3() 和 testMethod4() 持有的锁是同一个,即 MyService.java 类的对象。
(C)testMethod5() 持有的锁是字符串 "abc"。
说明 testMethod1() 和 testMethod2() 是同步关系,testMethod3() 和 testMethod4() 是同步关系.
上述三种类型中,A 和 C 之间是异步关系,B 和 C 之间是异步关系,A 和 B 之间是异步关系。
1.4.2 总结
关键字 synchronized 的主要作用就是保证同一时刻,只有一个线程可以执行某一个方法,或是某一个代码块,synchronized 可以修饰方法及代码块。
使用场景:当多个线程对同一个对象中的同一个实例变量进行操作时,为了避免出现非线程安全问题,就要使用 synchronized。
2. 死锁
如果一个线程持有一个锁并试图获取另一个锁时,就有死锁的危险。这是在多线程竞争使用多资源的程序中,有可能出现的情况。
死锁情况发生在第一个线程等待第二个线程所持有的锁,而第二个线程又在等待第一个线程持有的锁的时候,每个线程都不能继续运行,除非有一个线程运行完同步程序块。而恰恰因为哪个线程都不能继续进行,所以哪个线程都无法运行完同步程序块。
Java 既不检测也不采取办法避免这种状态,因此保证不发生死锁只能靠程序员自身的设计。
具体避免死锁的方法可见本人另一篇博客 :银行家算法 C++实现
3. 线程交互————wait() 和 notify()
3.1 生产者与消费者
具体的例子,如生产者与消费者:
有两个人,一个人在刷盘子,另一个在把盘子烘干。这两个人各自代表一个线程,他们之间有一个共享对象————碗架,刷好而等待烘干的盘子放在碗架上,显然,碗架上有刷好的盘子时,负责烘干的人才能开始工作;而如果刷盘子的人刷的太快,刷好的盘子占满了碗架时,他就不能继续工作了,要等到碗架上空位置了才行。
涉及多线程间共享数据操作时,除了同步问题之外,还会遇到的就是,如何控制相互交互的线程之间的运行速度,即多线程的同步。
上图说明的问题是,生产者生产一个产品后就放入共享对象中,而不管共享对象中是否已有产品。消费者从共享对象中取用产品,但不检测是否已经取过。
若共享对象中只能存放一个数据,可能会出现以下问题:
-
生产者比消费者快时,消费者会漏掉一些数据取不到。
-
消费者比生产者快时,消费者取的数据相同。
为了解决所出现的问题,在 Java 中可以用 wait() 和 notify()/notifyAll() 方法(在java.lang.Object中定义)协调线程间的运行速度(读取)关系。
注意:
-
在执行 wait() 调用的时候,Java 首先吧锁定标志返回给对象,因此即使一个线程由于执行 wait() 调用而被阻塞,它也不会影响其他等待锁定标志的线程的运行。
-
当一个线程被 notify() 后,它并不立即变为可执行状态,而仅仅是从等待队列中移入锁定标志队列中。这样在冲洗获得锁定标志之前,它仍旧不能继续运行。
在实际实现中,方法 wait() 既可以被 notify() 终止,也可以通过调用线程的 interrupt() 来终止。后一种情况下,wait() 会抛出一个 InterruptedException 异常,所以需要把它放在 try/catch 结构中。
3.2 守护线程
守护线程,是为其他线程提供服务的线程,它一般应该是一个独立的线程,它的 run() 方法是一个无限循环。
可以用 public boolean isDaemon()
来确定一个线程是否是守护线程,同时也可以用 public void setDaemon(boolean)
设定一个线程为守护线程。
一般地,守护线程都用来做辅助性工作,如用于提示、帮助等。