前言
synchronized
关键字是 java 中一旦涉及并发, 永远绕不过的一个知识点。 如果让你用自己的话, 解释一下 synchronized
关键字, 如果你说出 “被 synchronized
关键字所修饰的代码或方法块不会在同一时间被多个线程执行” 这种话, 那么恭喜你, 掉入了 synchronized
关键字最为常见的误区之一。
sychronized 的误区
- 错误认识: “被 sychronized 关键字所修饰的代码或方法块不会在同一时间被多个线程执行” 。
通过一段代码样例来说明上面这个说法为什么是错的。
class SyncThread implements Runnable {
private static int count;
public SyncThread() {
count = 0;
}
public void run() {
`synchronized` (this) {
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
}
}
}
public static void main(String[] args) {
SyncThread syncThread = new SyncThread();
Thread thread1 = new Thread(new SyncThread(), "SyncThread1");
Thread thread2 = new Thread(new SyncThread(), "SyncThread2");
thread1.start();
thread2.start();
}
}
如果说, 被 synchronized
修饰的代码块同一时间只有一个线程能执行的话, 那么程序的输出一定是按顺序从 0 一直到 199. 但是如果你把这个程序 copy 到 IDE 中运行几遍或者调大累加的循环次数, 就一定话发现结果有错乱的情况。
那么 synchronized
关键字如何使用才能保证同步性呢。 对上面的代码稍加修改即可。
class SyncThread implements Runnable {
private static int count;
public SyncThread() {
count = 0;
}
public void run() {
`synchronized` (this) {
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + ":" + (count++));
}
}
}
public static void main(String[] args) {
SyncThread syncThread = new SyncThread();
Thread thread1 = new Thread(syncThread, "SyncThread1");
Thread thread2 = new Thread(syncThread, "SyncThread2");
thread1.start();
thread2.start();
}
}
上面这段代码, 无论你运行多少次,或者调大累加的循环次数, 它的结果输出一定是有序的从 0 到 199 。
如果你不理解为什么输出是 0 到 199 而不是 0 到 100 , 那么你缺少了多线程最关键的知识点。
同一个进程中的多个线程拥有各自独立的 寄存器 (Register) 指令计数器(Program Counter, 一种特殊的寄存器)、栈内存空间( Stack ) 。 除了以上提到的, 进程中其他的资源都是线程间共享的
具体来说, 一个进程的多个线程会共享一个地址空间(Address space), 堆(heap), 静态数据(static data) 和 代码段(code segments) 和 文件句柄( file descriptors)。 还有其他的一些进程状态也会在线程间共享, 例如 进程id, 文件锁 file lock 等, 但是这些都属于细节实现的问题, 在编写应用的时候, 我们通常并不关心。
- 一个地址空间 就是指一块物理内存地址 到 逻辑内存地址的映射。所以当我们说一个进程中的所有线程共享相同的地址空间时, 意思是, 当不同的线程访问一个全局变量
foo
时, 他们拿到的是同样的内存地址, 访问的是同一个物理内存区域。
- 一个地址空间 就是指一块物理内存地址 到 逻辑内存地址的映射。所以当我们说一个进程中的所有线程共享相同的地址空间时, 意思是, 当不同的线程访问一个全局变量
- 本地变量存储在 “栈内存空间” 空间中, 所以每一个线程都会有一个本地变量的 copy 。所以上述的代码中的变量
i
在两个线程中各自都有一份, 所以两个线程在保证同步的情况下,会对共享的静态变量 count 一共进行 200 次 + 操作 。
Sychronized 关键字的正确理解
通过上述的例子, 可以看出 synchronized
关键字, 在同一时间是有可能被多个线程同时执行的。 synchronized
关键字的真正作用是:
- 多个线程试图调用同一个 Object 的
synchronized
方法时, 同一时间只能有一个线程执行 sychronized 所修饰的方法或代码块。
这里可能马上会有同学提出异议, 说 synchronized 修饰静态方法时,这是类级别的同步, 而不是对象级别的同步。 所以这里又要引入新的知识点: Java 中的对象锁
内部锁(Intrinsic Lock)
synchronized 关键字所提供的同步机制是通过 Java 语言中的 内部锁 (intrinsic lock)或 监控器锁 (monitor lock) 实现的。 (java API 文档中通常将其简称为 “监控器” monitor)。内部锁在同步机制的实现过程中起到了两个作用:
- 保证了对于一个对象的状态的访问是线程互斥的
- 建立了多线程行为间 先行发生(happens-before) 的关系, 这对于保障多线程读写操作的 可见性(visibility) 至关重要。
- 第 2 点有些晦涩, 因为这又是一个很长的知识点, 这里暂且略过。
每一个对象都有一个与之关联的内部锁。 通常, 当一个线程需要对一个对象的域变量进行排他性(互斥性)且保证一致性的访问时, 该线程必须在访问前, 先获得该对象的内部锁。 在访问结束以后, 释放该对象的内部锁。 在获得对象的内部锁到释放这个锁的期间, 我们称该线程拥有(Own) 这个内部锁。 在此期间, 其他线程如果试图获取该锁, 就会被阻塞。
当一个线程释放了一个内部锁后, 与之后发生的同一个锁的获取行为,就建立了一个 先行发生(happens-before)的关系。
Synchronized 如何使用对象锁
当一个线程调用某个对象的 synchronized
方法时, 它会自动获取这个方法所属对象的内部锁, 在方法调用完毕或被异常终止后(方法 return 后),该对象的锁就会被释放。
也就是说
Class SynchronizedCounter
{
public synchronized void add()
{
// do some add stuff
}
}
与
Class SynchronizedCounter
{
public void add()
{
synchronized(this)
{
// do some add stuff
}
}
}
的实现效果是相同的, 任意一个线程调用某个 synchronizedCounter
实例的 add()
方法时, 都需要获取该对象的内部锁。
当 synchronized 关键字修饰静态方法时,
public class SomeClass {
public synchronized static void methodA()
{
// do something
}
}
它其实等价于如下写法
public class SomeClass {
public void methodA()
{
synchronized (SomeClass.class)
{
// do something
}
}
}
在上面的这例子中, 一个线程如果调用 methodA , 就会去获取 SomeClass.class
这个实例对象的内部锁(intrinsic lock)。 没错, SomeClass.class
的本质是一个对象实例, 它是 Class
类的一个实例!
所以当多个线程试图调用 synchronized
所修饰的静态方法时, 本质上还是在争抢一个对象的内部锁。
synchronized 相关误区总结
sychronized 关键字所实现的同步机制都是基于 Object 的, 多个线程只有在调用同一个对象的 synchronized 方法时, 才会互斥。
在 Java 语言中, 并不存在所谓的 “类锁”。 synchonized 无论修饰静态方法或一般的成员方法, 都是通过对象的内部锁(Intrinsic Lock) 实现的。