并发编程七:深入理解synchronized(上)

深入理解synchronized(上)

Java共享内存模型带来的线程安全问题

public class SyncDemo {
	private static int count = 0;
	public static void increment(){
		count++;
	}
	public static void decrement(){
		count--;
	}
	public static void main(String[] args) throws InterruptedException {
		Thread t1 = new Thread(()->{
			for (int i = 0; i < 5000; i++) {
				increment();
			}
		},"t1");

		Thread t2 = new Thread(() -> {
			for (int i = 0; i < 5000; i++) {
				decrement();
			}
		},"t2");
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		System.out.println(count);
	}
}

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作。 我们可以查看 i++和 i–(i 为静态变量)的 JVM 字节码指令 ( 可以在idea中安装一个jclasslib 插件)
在这里插入图片描述
i++的JVM 字节码指令

getstatic i // 获取静态变量i的值 
iconst_1 // 将int常量1压入操作数栈 
iadd // 自增 
putstatic i // 将修改后的值存入静态变量i

i–的JVM 字节码指令

getstatic i // 获取静态变量i的值 
iconst_1 // 将int常量1压入操作数栈 
isub // 自减
putstatic i // 将修改后的值存入静态变量i

如果是单线程以上 8 行代码是顺序执行没有问题。但多线程下这 8 行代码可能交错运行。

临界区( Critical Section)
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区,其共享资源为临界资源。
上面代码中increment()和decrement()就是临界区,static int count就是临界资源。
竞态条件( Race Condition )
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。
为了避免临界区的竞态条件发生,有多种手段可以达到目的:

  • 阻塞式的解决方案:synchronized,Lock
  • 非阻塞式的解决方案:原子变量(CAS)

synchronized的使用
synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java内置的使用者看不到的锁被称为内置锁,也叫作监视器锁。
在这里插入图片描述
使用synchronized解决上述代码出现的问题。

private static String lock = "";
public static void increment(){
		synchronized (lock) {
			count++;
		}
	}
public static void decrement(){
		synchronized (lock) {
			count--;
		}
	}

synchronized底层原理

synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语 Mutex(互斥量),它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的 优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。
Java虚拟机通过一个同步结构支持方法和方法中的指令序列的同步:monitor。 同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码块是通过monitorenter(加锁)和monitorexit(解锁)来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态 之间来回切换,对性能有较大影响。

public static void increment(){
		synchronized (lock) {
			count++;
		}
	}

上面代码的字节码指令
在这里插入图片描述
为什么会出现两个monitorexit。当程序正常运行的时候第一个monitorexit执行完后有一个goto到24行执行return,就返回了。如果出现异常,就会执行第二个monitorexit。我们在使用lock的时候,需要在finally里面执行lock.unlock操作。但是synchronized并不需要。

public static synchronized void increment(){
			count++;
	}

synchronized加载方法上时
在这里插入图片描述
在这里插入图片描述
方法内部是不会有monitorentermonitorexit指令的。但是在方法的access_flags(访问标志)上会有中设置ACC_SYNCHRONIZED标志。
在这里插入图片描述
public static synchronized加起来就是0x0029。

Monitor(管程/监视器)

Monitor,直译为“监视器”,而操作系统领域一般翻译为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在Java 1.5之前,Java语言提供的唯一并发语言就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的。除了Java之外,C/C++、C#等 高级语言也都是支持管程的。synchronized关键字和wait()、notify()、notifyAll()这三个方法是Java中实现管程技术的组成部分。
MESA模型
在管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和MESA模型。现在正在广泛使用的是MESA模型。下面介绍MESA模型:
在这里插入图片描述
根据上图来看入口的等待队列比较好理解。条件变量等待队列主要是解决同步问题。
比如说一个线程进来了。其他线程被阻塞进入到等待队列。此时该线程需要另一个线程的结果,那么该线程执行wati()方法将自己处于等待状态,同时释放锁,那么另一个线程就可以执行了。等到另一个线程执行完成,执行notifyAll()方法,唤醒等待的线程,这个等待的线程就处于条件变量等待队列中,优先获取锁。

对于MESA管程模型来说,wait()的使用有一个编程范式:

 while(条件不满足) {
 wait();
}

防止其他线程调用notifyAll(),进行虚假唤醒。被唤醒的线程再次执行时可能条件又不满足了,所以循环检验条件。MESA模型的wait()方法还有一个超时参数,为了避免线程进入等待队列永久阻塞。
这个在wait()方法的注释中就有说到。
在这里插入图片描述

notify()和notifyAll()分别何时使用
满足以下三个条件时,可以使用notify(),其余情况尽量使用notifyAll():

  • 所有等待线程拥有相同的等待条件;
  • 所有等待线程被唤醒后,执行相同的操作;
  • 只需要唤醒一个线程。

Java语言的内置管程synchronized
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。模型如下图所示。
在这里插入图片描述
Monitor机制在Java中的实现
java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖ObjectMonitor 实现,这是JVM内部基于 C++ 实现的一套机制。
ObjectMonitor其主要数据结构如下(hotspot源码ObjectMonitor.hpp):

ObjectMonitor() {
    _header       = NULL; //对象头  markOop
    _count        = 0;  
    _waiters      = 0,   
    _recursions   = 0;   // 锁的重入次数 
    _object       = NULL;  //存储锁对象
    _owner        = NULL;  // 标识拥有该monitor的线程(当前获取锁的线程) 
    _WaitSet      = NULL;  // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
    _WaitSetLock  = 0 ;    
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
    FreeNext      = NULL ;
    _EntryList    = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;

解释下重要的属性:owner当线程1占有锁的时候,就指向线程1。cxq当线程竞争锁失败后进入到这个链表。当持有锁的线程执行wait方法等待后,根据不同的策略可能进入到cxq,也可能进入到EntryList。
在这里插入图片描述
在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,默认策略(QMode=0)是:如果EntryList为空,则将 cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。_EntryList不为空,直接从_EntryList中唤醒线程。

锁在对象中标识

对象的内存布局
synchronized加锁加在对象上,锁对象是如何记录锁状态的?
Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据 (Instance Data)和对齐填充(Padding)。

  • 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID, 偏向时间,数组长度(数组对象才有)等。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

在这里插入图片描述
对于这部分内容在jvm专门专题中再说。
回到之前的问题: synchronized加锁加在对象上,对象是如何记录锁状态的?锁状态被记录在每个对象的对象头的Mark Word中
对象头是如何记录锁状态的
对象头分为三个部分:Mark Word、Klass Pointer、数组长度(只有数组对象有)
Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为 32bit和64bit,官方称它为“Mark Word”。
其他两个在jvm专题中再分析。

给大家推荐一个可以查看普通java对象的内部布局工具JOL(JAVA OBJECT LAYOUT),使用此工具可以查看new出来的一个java对象的内部布局,以及一个普通的java对象占用多少字节。 引入maven依赖

<!-- 查看Java 对象布局、大小工具 -->
 <groupId>org.openjdk.jol</groupId>
 <artifactId>jol-core</artifactId>
 <version>0.10</version>

测试

public static void main(String[] args) {
		Object obj = new Object();
		//查看对象内部信息
		System.out.println(ClassLayout.parseInstance(obj).toPrintable());
	}

结果:
在这里插入图片描述

OFFSET:偏移地址,单位字节;
SIZE:占用的内存大小,单位为字节;
TYPE DESCRIPTION:类型描述,其中object header为对象头;
VALUE:对应内存中当前存储的值,二进制32位;

Mark Word的重要属性:

hash: 保存对象的哈希码。运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。
age: 保存对象的分代年龄。表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
biased_lock: 偏向锁标识位。由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
lock: 锁状态标识位。区分锁状态,比如11时表示对象待GC回收状态, 只有最后2位 锁标识(11)有效。
JavaThread*: 保存持有偏向锁的线程ID。偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动 作。这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。
epoch: 保存偏向时间戳。偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏 向哪个锁

64位JVM下的对象结构描述
在这里插入图片描述

  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争时,JVM使用原子操作而不是OS互斥,这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的Mark Word中设置指向锁记录的指针。
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。 如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理 等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针

根据上图锁标志位,由于无锁状态和偏向锁都是01,需要额外一位来判断是否是偏向锁。根据上图锁的各种标志位:
无锁的标识:001
偏向锁:101
轻量锁:00
重量锁:10

Mark Word中锁标记枚举:

enum { locked_value             = 0,    //00 轻量级锁 
         unlocked_value           = 1,   //001 无锁
         monitor_value            = 2,   //10 监视器锁,也叫膨胀锁,也叫重量级锁
         marked_value             = 3,   //11 GC标记
         biased_lock_pattern      = 5    //101 偏向锁

更直观的理解方式:
在这里插入图片描述

利用JOL工具跟踪锁标记变化

之前我们测试JOL工具用到的代码

public static void main(String[] args) {
		Object obj = new Object();
		//查看对象内部信息
		System.out.println(ClassLayout.parseInstance(obj).toPrintable());
	}

结果:
在这里插入图片描述
看红框中数字 001 这是就是锁的标志位。对应上面讲到的001代表无锁
上面代码修改成

public static void main(String[] args) {
		Object obj = new Object();
		//查看对象内部信息
		new Thread(()->{
			synchronized (obj) {
		System.out.println(Thread.currentThread().getName()+":"+ClassLayout.parseInstance(obj).toPrintable());
			}
		},"Thread1").start();
	}

在这里插入图片描述
00代表轻量级锁,后面3个8位就是指向持有锁线程的栈中的锁记录的内存地址。
JDK1.5之后对synchronized 进行优化,引入了偏向锁、轻量锁等概念,为什么不是偏向锁呢?
这里有个概念叫做偏向锁延迟解释这个概念之前先来了解什么是偏向锁

偏向锁

偏向锁是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了消除数据在无竞争情况下锁重入(CAS操作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果。

/***StringBuffer内部同步***/
public synchronized int length() { 
   return count; 
} 
//System.out.println 无意识的使用锁 
public void println(String x) { 
  synchronized (this) {
     print(x); newLine(); 
  } 

像我们使用的System.out.println、StringBuffer的append方法等里面都包含有synchronized ,为了避免单线程(无竞争情况下)锁性能的开销,引入偏向锁。

偏向锁延迟偏向
偏向锁模式存在偏向锁延迟机制:HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式。JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等 等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。 为了减少初始化时间,JVM默认延时加载偏向锁。

//关闭延迟开启偏向锁
-XX:BiasedLockingStartupDelay=0
//禁止偏向锁
-XX:-UseBiasedLocking 
//启用偏向锁
-XX:+UseBiasedLocking

我们关闭偏向锁延迟
在这里插入图片描述
再次执行上面代码:
在这里插入图片描述
现在使用默认的情况下,就是什么jvm参数都不添加的情况下,将上面代码修改在执行

public static void main(String[] args) throws InterruptedException {
		Thread.sleep(5000);
		Object obj = new Object();
		//查看对象内部信息
		new Thread(()->{
			synchronized (obj) {
		System.out.println(Thread.currentThread().getName()+":"+ClassLayout.parseInstance(obj).toPrintable());
			}
		},"Thread1").start();
	}

在这里插入图片描述
101就是偏向锁的标识。这就验证了偏向锁延迟。

偏向锁撤销和升级
有一个思考题:如果对象调用了hashCode,还会开启偏向锁模式吗?

public static void main(String[] args) throws InterruptedException {
		Thread.sleep(5000);
		Object obj = new Object();
		obj.hashCode();
		//查看对象内部信息
		new Thread(()->{
			synchronized (obj) {
		System.out.println(Thread.currentThread().getName()+":"+ClassLayout.parseInstance(obj).toPrintable());
			}
		},"Thread1").start();
	}

在这里插入图片描述
当锁对象调用了hashCode,偏向锁就变成了轻量级锁。为什么参考64位JVM下的对象结构描述图。
调用锁对象的obj.hashCode()或System.identityHashCode(obj)方法会导致该对象的偏向锁被撤销。因为对于一个对象,其HashCode只会生成一次并保存,偏向锁是没有地方保存hashcode的

  • 轻量级锁会在锁记录中记录 hashCode
  • 重量级锁会在 Monitor 中记录 hashCode

再考虑这么一个情况,上面调用 hashCode 是在synchronized 之前,称之为可偏向状态,如果调用了synchronized 称之为已偏向状态。那么如果在已偏向状态的情况下调用hashCode 呢?

public static void main(String[] args) throws InterruptedException {
		Thread.sleep(5000);
		Object obj = new Object();
		//查看对象内部信息
		new Thread(()->{
			synchronized (obj) {
				obj.hashCode();
System.out.println(Thread.currentThread().getName()+":"+ClassLayout.parseInstance(obj).toPrintable());
			}
		},"Thread1").start();
	}

在这里插入图片描述
偏向锁直接升级为重量级锁。
结论:当对象处于可偏向(也就是线程ID为0)和已偏向的状态下,调用HashCode计算将会使对象再也无法偏向:

  • 当对象可偏向时,MarkWord将变成未锁定状态,并只能升级成轻量锁;
  • 当对象正处于偏向锁时,调用HashCode将使偏向锁强制升级成重量锁。

在看这么一种情况,如果在偏向状态下调用了wait或者notify又是什么样的情况

public static void main(String[] args) throws InterruptedException {
		Thread.sleep(5000);
		Object obj = new Object();
		//查看对象内部信息
		new Thread(() -> {
			synchronized (obj) {
				try {
					obj.wait(100);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				System.out.println(Thread.currentThread().getName() + ":" + ClassLayout.parseInstance(obj).toPrintable());
			}
		}, "Thread1").start();
	}

在这里插入图片描述
直接升级为重量级锁。之前说过synchronized关键字和wait()、notify()、notifyAll()都是基于Monitor机制实现,所以直接把偏向锁升级为重量级锁。
再来看看notify()的情况

public static void main(String[] args) throws InterruptedException {
		Thread.sleep(5000);
		Object obj = new Object();
		//查看对象内部信息
		new Thread(() -> {
			synchronized (obj) {
				obj.notify();
				System.out.println(Thread.currentThread().getName() + ":" + ClassLayout.parseInstance(obj).toPrintable());
			}
		}, "Thread1").start();
	}

在这里插入图片描述
调用了notify()之后把偏向锁升级为轻量级锁。这就奇怪啦为什么wait()方法升级为重量级,notify()升级轻量级。不知道jvm底层是到底怎么去实现的。
就是当偏向锁消除,升级之后不一定就是轻量锁或者重量级锁。
总结一下什么情况下导致偏向锁消除和升级。

  • 调用hashCode 方法
  • 调用wait()方法和notify()方法等。

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁

package com.tuling.jucdemo.test;

import org.openjdk.jol.info.ClassLayout;

public class test1 {
	public static void main(String[] args) throws InterruptedException {
		Thread.sleep(5000);
		Object obj = new Object();
		//查看对象内部信息
		Thread thread1 = new Thread(() -> {
			synchronized (obj) {
				System.out.println(Thread.currentThread().getName() + ":" + ClassLayout.parseInstance(obj).toPrintable());
			}
		}, "Thread1");

		Thread thread2 = new Thread(() -> {
			synchronized (obj) {
				System.out.println(Thread.currentThread().getName() + ":" + ClassLayout.parseInstance(obj).toPrintable());
			}
		}, "Thread2");

		thread1.start();
		thread1.join();
		thread2.start();
		thread2.join();
		System.out.println(Thread.currentThread().getName() + ":" + ClassLayout.parseInstance(obj).toPrintable());
	}
}

结果:
在这里插入图片描述
在这里插入图片描述
从偏向锁升级为轻量级锁然后变成无锁。但这不是唯一的结果,多次运行锁的状态不一定是这样。

轻量级锁升级为重量级锁。当多个线程同一时间访问一个资源的时候。

public static void main(String[] args) throws InterruptedException {
		Thread.sleep(5000);
		Object obj = new Object();
		//查看对象内部信息
		Thread thread1 = new Thread(() -> {
			synchronized (obj) {
				System.out.println(Thread.currentThread().getName() + ":" + ClassLayout.parseInstance(obj).toPrintable());
			}
		}, "Thread1");

		Thread thread2 = new Thread(() -> {
			synchronized (obj) {
				System.out.println(Thread.currentThread().getName() + ":" + ClassLayout.parseInstance(obj).toPrintable());
			}
		}, "Thread2");
thread1.start();
thread2.start();
	}

在这里插入图片描述
还有两个问题:

  • 轻量级锁是否可以降级为偏向锁?
  • 重量级锁释放之后变为无锁,此时有新的线程来调用同步块,会获取什么锁?

这两个问题留到下篇解答
总结
对于synchronized来说,偏向锁和轻量级锁是在对象头中进行操作,不管有没有synchronized关键字,对象一开始(偏向锁延迟后)要么处于无锁状态,对象头的锁标识为001,要么处于偏向锁状态(这里包含可偏向状态和已偏向状态),对象头的锁标识为101,当有轻微竞争的情况下,偏向锁升级为轻量级锁对象头的锁标识是00。此时都只是在用户态去操作锁。当发生高并发的时候,轻量级锁会升级为重量级锁,此时通过 Monitor机制操作锁,这时候就是从用户态到内核态的转变。

上篇主要演示了偏向锁、轻量级锁、重量级锁状态,下篇会进行更深层次的总结和演示
锁对象状态转换
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值