9.28 synchronized使用

66 阅读6分钟

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 产生的不同对象,因此会失败。

  • 以下结果运行正确,当然本文执行结果都是这个,不做赘述了:

    lock-method.png

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 的值作为开关,这样是不可取的

show1.png

2 synchronized 的禁止指令重排

synchronized指令重排吗?

答案:答案是可以的。一个线程进入 synchronized 锁住的代码块,但是外部依然是多线程环境。可以看见锁住的代码块只有一个判断和new操作。这里一个new过程由三个原子操作:

① 堆区分配内存,

② 调用构造器方法,执行初始化,

③ 变量赋值。

其中②,③可能发生指令重排序。

如果一个线程在新建内存时优先执行了 ③ ,而另一个线程是可以发现为null是可以这个变量的,由于变量还未初始化,所以出错了。

那么为什么要指令重排序呢?

答案:这里涉及cpu的流水机制。所谓的cpu流水机制。

流水线冒险是说,下一条指令不得不阻塞,不能执行。比如取数-使用型冒险,即在取的数还没有取回来,装载的数就要使用的情况。此时重新安排代码,让取的数取回来之后,避免阻塞,也就提高了指令的效率。

可以参见如下图示,以洗衣机的流水线,类比了cpu读取指令的流水线过程。

show2.png

如下代码和注释,从汇编级别解释了指令重排的好处