Java 多线程同步:Synchronized 关键字

  • Post author:
  • Post category:java




内置锁

每个Java对象都对应着一个实现同步的锁,这个锁就是

内置锁

。之所以每个对象都有一个内置锁,是为了免去显式地创建锁对象。线程进入同步代码块或方法的时候会自动获得该锁,在退出同步代码块或方法时会释放该锁。

Java内置锁是一个互斥锁,这就是意味着最多只有一个线程能够获得该锁,当线程A尝试去获得线程B持有的内置锁时,线程A必须等待或者阻塞,直到线程B释放这个锁,如果B线程不释放这个锁,那么A线程将永远等待下去。



同步方法和同步代码块

  • 对象锁
  // 普通同步方法
 public synchronized void test(){ 
     ...
 }
 // 普通同步代码块
 public  void test(){ 
      synchronized(this){
          ...
      }
 } 

synchronized加锁的默认对象是同步方法或同步代码块所在类的对象,同步代码块加锁对象也可以是其他类对象。


实例:

public class CurrentThread {

	public static void main(String[] args) {
		SyncRunnable mRSyncRunnable=new SyncRunnable();
		Thread SyncThread1=new Thread(mRSyncRunnable,"SyncThread1");
		SyncThread1.start();
		Thread SyncThread2=new Thread(mRSyncRunnable,"SyncThread2");
		SyncThread2.start();
	}
	
	static class SyncRunnable implements Runnable{
		int i=0;

		@Override
		public void run() {
			testA();
			testB();
		}
		
		public synchronized void testA() {
			i++;
			System.out.println(Thread.currentThread().getName()+":--testA--i==>"+i);
		}
		
        public void testB() {
			synchronized (this) {
				i++;
				System.out.println(Thread.currentThread().getName()+":--testB--i==>"+i);
			}
		}
	}
}

运行结果:
SyncThread1:--testA--i==>1
SyncThread2:--testA--i==>2
SyncThread2:--testB--i==>3
SyncThread1:--testB--i==>4


解析:

当两个并发线程(SyncThread1和SyncThread2)访问同一个对象(mRSyncRunnable)中的同步方法和同步代码块时,在同一时刻只能有一个线程得到执行,另一个线程受阻塞,必须等待当前线程执行完这个代码块以后才能执行该代码块。SyncThread1和SyncThread2是互斥的,因为在执行synchronized代码块时会锁定当前的对象,只有执行完该代码块才能释放该对象锁,下一个线程才能执行并锁定该对象。


注意:


虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。

  • 类锁
// 静态同步方法
 public static synchronized void test(){ // 类锁
     ...
 }
 
 // 静态同步代码块
 public static void test(){ 
      synchronized(Test.class){ // 类锁
          ...
      }
 }

synchronized加锁的对象是当前静态方法所在类的Class对象。


实例:

public class CurrentThread {

	public static void main(String[] args) {
		SyncRunnable mRSyncRunnable1=new SyncRunnable();
		Thread SyncThread1=new Thread(mRSyncRunnable1,"SyncThread1");
		SyncThread1.start();
		SyncRunnable mRSyncRunnable2=new SyncRunnable();
		Thread SyncThread2=new Thread(mRSyncRunnable2,"SyncThread2");
		SyncThread2.start();
	}
	
	static class SyncRunnable implements Runnable{
		static int i=0;

		@Override
		public  void run() {
			testA();
			testB();
		}
		
		public synchronized static void testA() {
			i++;
			System.out.println(Thread.currentThread().getName()+":--testA--i==>"+i);
		}
		
        public static void testB() {
			synchronized (SyncRunnable.class) {
				i++;
				System.out.println(Thread.currentThread().getName()+":--testB--i==>"+i);
			}
		}
	}

}

运行结果:
SyncThread1:--testA--i==>1
SyncThread1:--testB--i==>2
SyncThread2:--testA--i==>3
SyncThread2:--testB--i==>4


解析:

虽然mRSyncRunnable1和mRSyncRunnable2是SyncRunnable 的两个对象,但在SyncThread1和SyncThread2并发执行时却保持了线程同步。这是因为run中调用了静态方法 testA和testB,而静态方法是属于类的,所以SyncThread1和SyncThread2相当于用了同一把锁SyncRunnable.class。



Synchronized底层实现原理

在Java代码中,我们只是使用了synchronized关键字就实现了同步效果。通过反编译一下同步代码块和同步方法来了解一下过程。

先执行javac 类.java 文件,再执行javap -v 类名

// 同步代码块
public  void testB() {
		synchronized (SyncDemo.class) {
			i++;
			System.out.println(Thread.currentThread().getName()+":--testB--i==>"+i);
		}
}

同步代码反编译

从图中标红地方看出,字节码中会在同步代码块的入口和出口加上monitorenter和moniterexit指令。当执行到monitorenter指令时,线程就会去尝试获取该对象对应的Monitor的所有权,即尝试获得

该对象的锁

当该对象的 monitor 的计数器count为0时,如果线程可以成功取得monitor,同时将计数器值设置为 1,取锁成功。如果当前线程已经拥有该对象monitor的持有权,那它可以重入这个 monitor ,计数器的值也会加 1。而当执行monitorexit指令时,锁的计数器会减1。

如果其他线程已经拥有monitor的所有权,那么当前线程获取锁失败将被阻塞并进入到_EntryList中,直到等待的锁被释放为止。也就是说,当所有相应的monitorexit指令都被执行,计数器的值减为0,执行线程将释放 monitor(锁),其他线程才有机会持有 monitor 。

// 同步方法
public  synchronized void testB() {
			i++;
			System.out.println(Thread.currentThread().getName()+":--testB--i==>"+i);
}

同步方法反编译

从上图可以看出,没有monitorenter和moniterexit两条指令,而是在方法的flag上加入了ACC_SYNCHRONIZED的标记位。因为整个方法都是同步代码,因此就不需要标记同步代码的入口和出口了。当线程线程执行到这个方法时会判断是否有这个ACC_SYNCHRONIZED标志,如果有的话则会尝试获取monitor对象锁。执行步骤与同步代码块一致。


注意:

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁和监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。


小结:


用 synchronized修饰的同步方法中,是通过监视器锁来实现同步效果的,而这个监视器锁存在于 Java对象头中。



锁的内部机制

在JVM中,对象在内存中存储的布局可以分为三个区域,分别是

对象头



实例数据

以及

填充数据

  • 对象头

在HotSpot虚拟机中,对象头又被分为两部分,分别为:Mark Word(标记字段)、Class Pointer(类型指针)。如果是数组,那么还会有数组长度。

  • 填充数据

由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

  • 实例数据

存放类的属性数据信息,包括父类的属性信息,这部分内存按4字节对齐。



对象头

在对象头的Mark Word中主要存储了对象自身的运行时数据,例如哈希码、GC分代年龄、锁状态、线程持有的锁、偏向线程ID以及偏向时间戳等。同时,Mark Word也记录了对象和锁有关的信息。

Mark Word在不同锁状态下存储的内容有所不同。以32位JVM中对象头的存储内容如下图所示

在这里插入图片描述

在这里插入图片描述

从上图看出:

  • 无锁状态和偏向锁标记位为01,轻量级锁的状态为00,重量级锁的状态为10
  • 当对象为偏向锁时,Mark Word存储了偏向线程的ID
  • 当状态为轻量级锁时,Mark Word存储了指向线程栈中Lock Record的指针
  • 当状态为重量级锁时,Mark Word存储了指向堆中的Monitor对象的指针


Monitor对象

用 Synchronized修饰的同步方法,本质上是通过获取Java对象头中的监视器锁来实行同步的。Monitor对象被称为

管程

或者

监视器锁

。在Java中,每一个对象实例都会关联一个Monitor对象。这个Monitor对象既可以与对象一起创建销毁,也可以在线程试图获取对象锁时自动生成。当这个Monitor对象被线程持有后,它便处于锁定状态。

在HotSpot虚拟机中,Monitor是由

ObjectMonitor

实现的,它是一个使用C++实现的类,主要数据结构如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //用来记录当前线程获取该锁的次数
    _waiters      = 0, //等待线程数
    _recursions   = 0; //锁的重入次数
    _object       = NULL;
    _owner        = NULL; //表示持有ObjectMonitor对象的线程
    _WaitSet      = NULL; //线程队列:存放处于wait状态的线程
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //线程队列:存放正在等待锁释放而处于block状态的线程
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0; //前一个持有者的线程ID
  }
  • _count

用来记录当前线程获取该锁的次数,成功获取到锁后count会加1,释放锁时count减1。

  • _ower

用来指向持有monitor的线程,它的初始值为NULL,表示当前没有任何线程持有monitor。当一个线程成功持有该锁之后会保存线程的ID标识,等到线程释放锁后_ower又会被重置为NULL

  • _WaitSet

调用了锁对象的wait方法后的线程会被加入到这个队列中

  • _cxq

是一个阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList

  • EntryList

没有抢到锁的线程会被放到这个队列


Monitor 工作原理

:

在这里插入图片描述


工作流程:

  1. 当多个线程同时访问同一同步代码时,先获取到monitor锁的线程,会将monitor中的ower设置为该线程的ID,同时monitor中的count进行加1。
  2. 而其他线程则进入 _EntryList 队列中,处于阻塞状态,直到当前线程 _owner 释放了 monitor(此时_count为0)。
  3. 这些处于 _EntryList中阻塞的线程才会被唤醒然后去竞争 monitor,新竞争到 monitor 的线程就会成为新的 _owner。
  4. 获取到 monitor 的线程在调用wait()方法后,_owner会释放monitor,_count减一,该线程会加入到 _WaitSet 队列中,直到调用 notify()/notifyAll() 方法出队列,再次获取到 monitor。


EntryList 与 _WaitList 的区别

  • EntryList

处于 _EntryList 队列中的线程是还没有进入到同步方法中

  • _WaitList

处于 _WaitList 队列的线程是已经进入到了同步方法中,但是由于某些条件(调用了wait()方法)暂时释放了 monitor,等待某些条件(调用notify()/notifyAll()方法)再次获取到 monitor。



Synchronized 锁升级优化

在JDK1.6版本中对Synchronized进行了锁优化,引入了偏向锁和轻量级锁。一般锁有4种状态:

无锁状态



偏向锁状态



轻量级锁状态



重量级锁状态

。随着锁竞争激烈程度,锁的状态会出现一个升级的过程。即可以从偏向锁升级到轻量级锁,再升级到重量级锁。锁升级的过程是单向不可逆的,即一旦升级为重量级锁就不会再出现降级的情况。

  • 偏向锁

在大多数情况下锁不仅不存在多线程竞争关系,而且大多数情况都是被同一线程多次获得。偏向锁主要解决无竞争下的锁性能问题。


偏向锁的核心思想

:如果一个线程获得了锁,那么锁就进入偏向模式,此时MarkWord的结构也变为偏向锁结构,即将对象头中Mark Word的第30bit的值改为1,并且在MarkWord中记录该线程的ID。当这个线程再次请求锁时,无需再做任何同步操作,即可获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提升了程序的性能。

  • 轻量级锁

轻量级锁优化性能的依据是

对于大部分的锁,在整个同步生命周期内都不存在竞争

。当升级为轻量级锁之后,MarkWord的结构也会随之变为轻量级锁结构。JVM会利用CAS尝试把对象原本的Mark Word更新为LockRecord的指针,成功就说明加锁成功,改变锁标志位为00,然后执行相关同步操作。


适应的场景:

线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁就会失效,进而膨胀为重量级锁。

  • 重量级锁

轻量级锁不断自旋膨胀之后,就会升级为重量级锁。重量级锁时依赖对象内部的 monitor 锁来实现,而 moitor 又依赖系统的 MutexLock(互斥锁) 来实现,所以重量级锁也被称为 互斥锁 。

重量级锁开销比较大原因:当系统检查到锁时重量级锁时,会把正在等待获取锁的线程进行阻塞,被阻塞的线程不会消耗cpu,但是阻塞和唤醒线程,都需要操作系统来处理,这就需要从用户态转换到内核态,而从用户态到内核态的切换,需要通过系统调用来完成。系统调用的过程中会发生cpu上下文切换,一次系统调用的过程,需要发生两次上下文切换。而这个过程很多时候比同步代码块所需时间还长。



总结

  • 一个类的对象锁和另一个类的对象锁是没有关联的,当一个线程获得A类的对象锁时,它同时也可以获得B类的对象锁。
  • Synchronized是一个内置锁的加锁机制,当某个方法加上synchronized关键字后,就表明要获得该内置锁才能执行,并不能阻止其他线程访问不需要获得该内置锁的方法。



版权声明:本文为xufei5789651原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。