有两种机制防止代码块受到并发访问的干扰。一种是提供一个 synchronized 关键字达到这一目的;另外一种是引入了 Lock 类。
synchronized 关键字
关键字 synchronized 取得的锁都是对象锁。如果给一个类的静态方法加上 synchronized ,就是给该类对应的 Class 类上锁,可以对类的所有对象实例起作用。
同步锁对象 Lock 类
通过显式定义锁对象来实现同步,同步锁由 Lock 对象充当。Lock 提供了更广泛的锁定操作,并且支持多个相关的 Condition 对象。某些锁可能允许对共享资源并发访问,如 ReadWriteLock(读写锁)。
Lock、ReadWriteLock 是 Java 提供的两个根接口,并为 Lock 提供了 ReentrantLock(可重入锁) 实现类,为 ReadWriteLock 提供了 ReentrantReadWriteLock 实现了。
使用 ReentrantLock 保护代码块:
1 | //定义锁对象 |
这一结构确保任何时刻只有一个线程进入临界区。一旦一个线程封锁了锁对象,其他任何线程都无法通过 lock 语句。当其他线程调用 lock 时,它们被阻塞,直到第一个线程释放锁对象。
Lock 提供了同步方法和同步代码块所没有的其他功能,包括用于非块结构的 tryLock() 方法,以及试图获取可中断锁的 lockInterruptibly() 方法,还有获取超时失效锁的 tryLock(long,TimeUnit) 方法。
每一个实例对象都有自己的 ReentrantLock 对象。如果两个线程试图访问同一个实例对象,那么锁将以串行方式提供服务。但是,如果两个线程访问不同的实例,每一个线程得到不同的锁对象,两个线程都不会发生阻塞。
串行是指多个任务时,各个任务按顺序执行,完成一个之后才能进入下一个。
锁是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个 持有计数(hold count) 来跟踪对 lock 方法的嵌套调用。线程在每一次调用 lock 都要调用 unlock 来释放锁。因此,被一个锁保护的代码可以调用另一个使用相同的锁的方法。
条件对象
关键字 synchronized 与 wait() 和 notify()/notifyAll() 方法相结合可以实现 等待/通知 机制。ReentrantLock 也可以实现同样的功能,但需要借助 Condition 对象。Codition 对象可以实现多路通知功能,也就是在一个 Lock 对象里面可以创建多个 Condition(即对象监视器) 实例,线程对象可以注册在指定的 Condition 中,从而可以有选择性地进行线程通知,在调度上更具有灵活性。
Condition 实例被绑定在一个 Lock 对象上,要想获得特定 Lock 实例的 Condition 实例,调用 Lock 对象的的 newCondition() 方法即可。Condition 类提供了 await()、signal()、signalAll() 三个方法,分别对应于 Object 类的 wait()、notify()、notifyAll(),所实现的功能是一样的。Lock 替代了同步方法或同步代码块,Condition 替代了同步监视器的功能。
等待获得锁的线程和调用了 await() 方法的线程存在本质上的不同。一旦一个线程调用了 await() 方法,它进入该条件的等待集。当锁可用时,该线程不能马上解除阻塞状态。它将继续处于阻塞状态,直到另一个线程调用同一条件上的 signalAll() 方法时为止。
调用 signalAll() 会重新激活因为这一条件而等待的所有线程。当这些线程从等待集当中移出时,它们再次成为可运行的,调度器将再次激活它们。同时,它们将试图重新进入该对象。一旦锁成为可用的,它们中的某个将从 await() 调用返回,获得该锁并从被阻塞的地方继续执行。
最重要的是最终需要某个其他线程调用 signalAll() 方法。当一个线程调用 await() 方法时,它没有办法重新激活本身,如果没有其他线程来重新激活等待的线程,它就永远不再运行。这将导致 死锁(deathlock) 现象。
对比 synchronized,synchronized 相当于整个 Lock 对象中只有一个单一的 Condition 对象,所有的线程都注册在这唯一一个条件上
监视器概念:锁和条件时线程同步的强大工具。但是严格来讲,它们不是面向对象的。
在Java中,监视器具有如下特性:
- 监视器是只包含私有域的类。
- 每个监视器类的对象有一个相关的锁。
- 使用该锁对所有的方法进行加锁。
- 该锁可以有任意多个相关条件。
将静态方法声明为 synchronized 也是合法的。如果调用这种方法,该方法获得相关的类对象的内部锁。例如,如果 Bank 类有一个静态同步方法,那么当该方法被调用时,Bank.class 对象的锁被锁住。因此,没有其他线程可以调用同一个类的这个或任何其他的同步静态方法。
内部锁和条件存在一些局限:
- 不能中断一个正在试图获得锁的线程。
- 试图获得锁时不能设定超时。
- 每个锁仅有单一条件,可能时不够的。
下面是一个利用 ReentrantLock 和 多个 Condition 进行线程通信,交替打印星号的例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104public class Tes
{
public static void main(String[] args) throws IOException
{
MyService myService = new MyService();
ThreadA[] threadAs = new ThreadA[10];
ThreadB[] threadBs = new ThreadB[10];
for (int i = 0; i < 10; i++)
{
threadAs[i] = new ThreadA(myService);
threadBs[i] = new ThreadB(myService);
threadAs[i].start();
threadBs[i].start();
}
}
}
class MyService
{
private ReentrantLock lock = new ReentrantLock();
private Condition conditionA = lock.newCondition();
private Condition conditionB = lock.newCondition();
private boolean hasValue = false;
public void set()
{//生产
try
{
lock.lock();
while (hasValue) //请务必注意这里要用 while 而不要用 if,
{ //目的是在唤醒后重新检测条件,避免重复生产
conditionA.await();
}
System.out.println(Thread.currentThread().getName() + " 生产★");
hasValue = true;
conditionB.signalAll();//把消费者线程全唤醒
} catch (InterruptedException e)
{
e.printStackTrace();
} finally
{
lock.unlock();
}
}
public void get()
{//消费
try
{
lock.lock();
while (!hasValue) //请务必注意这里要用 while 而不要用 if,
{ //目的是在唤醒后重新检测条件,避免重复消费
conditionB.await();
}
System.out.println(Thread.currentThread().getName() + " 消费☆");
hasValue = false;
conditionA.signalAll();
} catch (InterruptedException e)
{
e.printStackTrace();
} finally
{
lock.unlock();
}
}
}
class ThreadA extends Thread
{//生产者
private MyService service;
public ThreadA(MyService myService)
{
this.service = myService;
}
@Override
public void run()
{
for (int i = 0; i < Integer.MAX_VALUE; i++)
{
service.set();
}
}
}
class ThreadB extends Thread
{//消费者
private MyService service;
public ThreadB(MyService myService)
{
this.service = myService;
}
@Override
public void run()
{
for (int i = 0; i < Integer.MAX_VALUE; i++)
{
service.get();
}
}
}
使用 ReentrantReadWriteLock 类
类 ReentrantLock 具有完全互斥排他的效果,即同一时间只有一个线程在执行 ReentrantLock.lock() 方法后面的任务,这样做虽然保证了实例变量的线程安全性,但效率非常低下。因此提供了一个可重入读写锁类,在某些不需要操作实例变量的方法中,完全可以使用读写锁来提升方法的运行速度。
读写锁,顾名思义有两个锁,一个是读操作相关的锁,也称为 共享锁;另一个是写操作相关的锁,也叫 排他锁。多个读锁之间不互斥,读锁与写锁互斥,写锁与写锁互斥。在没有线程进行写入操作时,进行读取操作的多个 Thread 都可以获取读锁,而进行写入操作的 Thread 只有在获取写锁后才能进行写入操作。即多个 Thread 可以同时进行读取操作,但是同一时刻只允许一个 Thread 进行写入操作。
多线程的死锁
可以用 JDK 自带的工具来监测是否有死锁的现象。在JVM运行过程中打开 cmd,执行 JDK 的 bin 目录下的 jps 命令,得到允许 main 方法的主线程的 id(假设3244) 值,再执行 jstack 命令:jstack -l 3244
,即可查看结果是否存在死锁。
volatile 关键字
关键字 volatile 的主要作用是使变量在多个线程间可见。其原理是强制从公共堆中取得变量的值,而不是从线程的工作内存中取得变量的值,也就是多线程读取共享变量时可以获得最新的值。
volatile 关键字增加了实例变量在多个线程之间的可见性,但其最致命的缺点是不支持原子操作。典型的例子就是 i– 和 i++。
下面将 synchronized 和 volatile 进行一下比较:
- volatile 是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 要好,并且 volatile 只能修饰于变量,而 synchronized 可以修饰方法以及代码块。
- 多线程访问 volatile 不会发生阻塞,而 synchronized 会出现阻塞。
- volatile 能保证数据的可见性,但不能保证原子性;而 synchronized 可以保证原子性,也可以间接保证可见性,因为它会将私有内存和公共内存中的数据同步。
- 再次重申,volatile 解决的是变量在多个线程之间的可见性;而 synchronized 解决的是多个线程之间访问资源的同步性。
图解演示 volatile 出现非线程安全的原因
下图是变量在内存中工作的过程:
- read 和 load 阶段:从主内存复制变量到当前线程的工作内存。
- use 和 assign 阶段:执行代码,改变共享变量的值。
- store 和 write 阶段:用工作内存数据刷新主内存对应变量的值。
在多线程环境中,use 和 assign 是多次出现的,但这一操作并不是原子性,也即是在 read 和 load 之后,如果主内存 count 变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,也就是私有内存和公共内存中的变量不同步,所以计算出来的结果会和预期不一样,导致非线程安全问题。
公平锁和非公平锁
锁 Lock 分为 公平锁 和 非公平锁。公平锁 表示线程获取锁的顺序是按照线程加锁的