9.28 synchronized使用
synchronized 的三种使用方式
先不讨论synchronized的优化和底层原理,仅讨论如何使用synchronized,主要分为三种方式:锁方法,锁代码块,锁类
1 锁方法
先看代码:
/* 方法锁,锁住对象 */
public synchronized void methodSyn() {
try {
System.out.println("线程名称为: "+Thread.currentThread().getName()+"在 "+System.currentTimeMillis()+" 进入同步代码块");
System.out.println("in synchronized");
System.out.println("线程名称为: "+Thread.currentThread().getName()+"在 "+System.currentTimeMillis()+" 离开同步代码块");
}catch(Exception e) {
e.printStackTrace();
}
}
-
以上代码使用 synchronized 关键自修饰了一个方法,注意锁住的是一个类的同一个对象,当同一个对象都调用了这个方法就会形成阻塞。
public static void main(String args[]) { int i = 2; while(i > 0) { new Thread(()->{ // new TestThread().methodSynClass(); mTestThread.methodSyn(); // mTestThread.methodSynObj(); }).start(); i--; } } -
上述代码中,mTestThread 是同一个对象,调用时,Thread 就锁住了。这里注意匿名 Thread 产生的不同对象,因此会失败。
-
以下结果运行正确,当然本文执行结果都是这个,不做赘述了:
2 锁代码块
先看代码:
/* 同步代码块锁,锁住对象 */
public void methodSynObj() {
try {
synchronized(this) { // this锁和 对象锁是一个意思
System.out.println("线程名称为: "+Thread.currentThread().getName()+"在 "+System.currentTimeMillis()+" 进入同步代码块");
System.out.println("in synchronized");
System.out.println("线程名称为: "+Thread.currentThread().getName()+"在 "+System.currentTimeMillis()+" 离开同步代码块");
}
}catch(Exception e) {
e.printStackTrace();
}
}
-
这里的代码块锁和方法锁不太一样,代码块内部是互斥代码区,而代码块外部则是多线程环境,比如,修改为如下代码:hello1和hello2所在区域虽然在方法内部,但是由于在synchronized代码块外,所以就是多线程环境了。
/* 同步代码块锁,锁住对象 */ public void methodSynObj() { try { System.out.println("hello1" + Thread.currentThread().getName()); System.out.println("hello2" + Thread.currentThread().getName()); synchronized(this) { // this锁和 对象锁是一个意思 ... } }catch(Exception e) { e.printStackTrace(); } } -
代码执行结果同上
3 锁类
static 方法锁和 class 锁:static 方法锁,因为类方法只有一个,所以属于类的就锁住了。
先看代码:
/* 同步代码块锁,锁住类 */
public void methodSynClass() {
try {
synchronized(TestThread.class) {
System.out.println("线程名称为: "+Thread.currentThread().getName()+"在 "+System.currentTimeMillis()+" 进入同步代码块");
System.out.println("in synchronized");
System.out.println("线程名称为: "+Thread.currentThread().getName()+"在 "+System.currentTimeMillis()+" 离开同步代码块");
}
}catch(Exception e) {
e.printStackTrace();
}
}
-
其实主要就是 TestThread.class,表示锁住了这个类的所有对象,当主函数调用该类创建的对象时,就会形成阻塞。欣喜的是,这里可以使用匿名类来简化代码了。
public static void main(String args[]) { int i = 2; while(i > 0) { new Thread(()->{ new TestThread().methodSynClass(); // mTestThread.methodSyn(); // mTestThread.methodSynObj(); }).start(); i--; } } -
代码执行结果同上
synchronized 的底层原理
从 synchronized 保证同步和禁止指令重排两个方面来谈谈其底层原理
1 synchronized 的同步性
首先将以下代码运行,生成 Singleton.class 文件
public class Singleton {
private volatile static Singleton singleton;
private Singleton() {}
public static Singleton getSingleton() {
if(singleton == null) {
synchronized (Singleton.class) {
if(singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
其次,用javap -v singleton.class 反编译后,发现是由monitorenter-monitorexit-monitorexit这对虚拟机指令来维护了一个互斥区,如下,第 10 行,第 28 行,第 34 行
10: monitorenter
11: getstatic #2 // Field singleton:Lcom/transsion/single/Singleton;
14: ifnonnull 27
17: new #3 // class com/transsion/single/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field singleton:Lcom/transsion/single/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
经过上面的代码,提出疑惑,那么monitor是什么呢?
答案:其实,monitor对象是monitor机制的核心,它本质上是jvm用c/c++语言定义的一个数据类型。对应的数据结构保存了线程同步所需的信息,比如保存了被阻塞的线程的列表,还维护了一个基于mutex的锁,monitor的线程互斥就是通过mutex互斥锁实现的。
这里又提出一个疑惑,那么 mutex 互斥锁又是怎么实现的?
答案:mutex依赖于操作系统汇编级别的同步原语来实现。比如MIPS指令集中,就是loadlink 和sore condition 这一对指令集。
下面讨论一下最简单的原子交换原语,这个原语是将寄存器中的一个值和存储器中的一个值相互交换。怎么做到的呢?
假设存储器中的锁变量为0时表示解锁,为1时表示加锁,当一个处理器尝试对锁单元加锁,方法是用一个寄存器中的1和锁单元交换。交换以后该锁单元的新值为 1 ,返回值如果是 1,表明这个锁已被其他占用;否则返回0,表示锁是自由的,尝试加锁成功。
要保证这一系列操作是原子的,也就是说不可分割,不能被打断,汇编语言中用 ll 和 sc 来实现。
最后,再提出一个疑惑,那么终极大boss :ll 和 sc 又是怎么实现同步原语的呢?
答案:可以参加如下代码和代码中的注释
/* 目标:实现原子交换,即 $s4 中的值和内存中锁单元的值交换,锁单元的基址保存在 $s1 中 */
again: addi $t0, $zero, 1 // $t0 = 0 + $s4,此时 $t0 中保存了 $s4 的值 1
ll $t1, 0($s1) // $t1 = Memory[$s1 + 0],此时 $t1 中保存了 $s1 指向的锁单元的值 ?
sc $t0, 0($s1) /* Memory[$s1 + 0] = $t0,当执行成功,此时 $s1 所指向的锁单元的值就是 $s4 的值了,并且将 $t0 的值修改为 1。
* 当执行失败,不保存 $s4 的值到锁单元,并且将 $t0 的值修改为 0。
*/
beq $t0, $zero, again /* if $tp == $zero, jump again; else continue,如果 $t0 为 0,则说明执行失败,应该跳到 again,
* 如果 $t0 为 1,则说明锁单元的值已经是 s4 了,
* 接下来只需要将 s4 中的值改为锁单元的值就行了
*/
add $s4, $zero, $t1 // $s4 = $zero + $t1,前面将锁单元的值放在了 $t1 中,只需要将其赋值到 $s4 即可
/* 提出思考:按照常理来说交换俩个值使用一个 temp 即可,
* 但是此时却采用了两个 temp,
* 这里多出来的 temp0 其实有两个作用:
* ① 是 sc 执行成功与失败的开关,
* ② 携带 $s4 的值,便于交换
* 如果没有 temp0 的话,那么将要修改 $s4 的值作为开关,这样是不可取的
2 synchronized 的禁止指令重排
synchronized指令重排吗?
答案:答案是可以的。一个线程进入 synchronized 锁住的代码块,但是外部依然是多线程环境。可以看见锁住的代码块只有一个判断和new操作。这里一个new过程由三个原子操作:
① 堆区分配内存,
② 调用构造器方法,执行初始化,
③ 变量赋值。
其中②,③可能发生指令重排序。
如果一个线程在新建内存时优先执行了 ③ ,而另一个线程是可以发现为null是可以这个变量的,由于变量还未初始化,所以出错了。
那么为什么要指令重排序呢?
答案:这里涉及cpu的流水机制。所谓的cpu流水机制。
流水线冒险是说,下一条指令不得不阻塞,不能执行。比如取数-使用型冒险,即在取的数还没有取回来,装载的数就要使用的情况。此时重新安排代码,让取的数取回来之后,避免阻塞,也就提高了指令的效率。
可以参见如下图示,以洗衣机的流水线,类比了cpu读取指令的流水线过程。
如下代码和注释,从汇编级别解释了指令重排的好处