高并发学习之05关键字synchronized

1.synchronized简介

在前文中JMM内存模型中我们已经了解了Java内存模型的一些知识,并且已经知道出现线程安全的主要来源于JMM的设计,主要集中在主内存和线程的工作内存而导致的内存可见性问题,以及重排序导致的问题。线程运行时拥有自己的栈空间,会在自己的栈空间运行,如果多线程间没有共享的数据也就是说多线程间并没有协作完成一件事情,那么,多线程就不能发挥优势,不能带来巨大的价值。那么共享数据的线程安全问题怎样处理?很自然而然的想法就是每一个线程依次去读写这个共享变量,这样就不会有任何数据安全的问题,因为每个线程所操作的都是当前最新的版本数据。那么,在Java关键字synchronized就具有使每个线程依次排队操作共享变量的功能。
在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了,Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。下面这个案例,然后通过synchronized关键字来修饰在inc的方法上。看下执行结果:

public class Demo{
  private static int count=0;
  public static void inc(){
    synchronized (Demo.class) {
      try {
        Thread.sleep(1);
     } catch (InterruptedException e) {
        e.printStackTrace();
     }
      count++;
   }
 }
  public static void main(String[] args) throws InterruptedException {
    for(int i=0;i<1000;i++){
      new Thread(()->Demo.inc()).start();
   }
    Thread.sleep(3000);
    System.out.println("运行结果"+count);
 }
}
2. synchronized的三种应用方式

synchronized有三种方式来加锁,分别是:

  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁,如事例1 ,事例2
  • 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁,如事例3
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。如事例4、事例5

事例1:

//锁住的是当前实例
synchronized (this) {
	....
}

事例2:

//锁住的是当前实例
public synchronized void method1(){}

事例3:

//锁住的是当前类
public static synchronized void method1(){}

事例4:

//锁住的是类
synchronized (Demo.class) {
	....
}

事例5:

//锁住的是配置的类
Object lock=new Object
synchronized (lock) {
	....
}

synchronized扩后后面的对象是一把锁,在java中任意一个对象都可以成为锁,简单来说,我们把object比喻是一个key,拥有这个key的线程才能执行这个方法,拿到这个key以后在执行方法过程中,这个key是随身携带的,并且只有一把。如果后续的线程想访问当前方法,因为没有key所以不能访问只能在门口等着,等之前的线程把key放回去。所以,synchronized锁定的对象必须是同一个,如果是不同对象,就意味着是不同的房间的钥匙,对于访问者来说是没有任何影响的。

3.synchronized的字节码指令

通过javap -v(JAVAP 查看方式) 来查看对应代码的字节码指令,对于同步块的实现使用了monitorenter和monitorexit指令,前面我们在讲JMM线程间通信的时候,提到过这两个指令,他们隐式的执行了Lock和UnLock操作,用于提供原子性保证。monitorenter指令插入到同步代码块开始的位置、monitorexit指令插入到同步代码块结束位置,jvm需要保证每个monitorenter都有一个monitorexit对应。
这两个指令,本质上都是对一个对象的监视器(monitor)进行获取,这个过程是排他的,也就是说同一时刻只能有一个线程获取到由synchronized所保护对象的监视器线程执行到monitorenter指令时,会尝试获取对象所对应的monitor所有权,也就是尝试获取对象的锁;而执行monitorexit,就是释放monitor的所有权。

下面是第一个事例中部分JAVAP指令:

//inc方法生成指令
public static void inc();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=0
         0: ldc           #2                  // class com/herman/concurrent/one/Demo8
         2: dup
         3: astore_0
         4: monitorenter   //指令进入
         5: lconst_1
         6: invokestatic  #3                  // Method java/lang/Thread.sleep:(J)V
         9: goto          17
        12: astore_1
        13: aload_1
        14: invokevirtual #5                  // Method java/lang/InterruptedException.printStackTrace:()V
        17: getstatic     #6                  // Field count:I
        20: iconst_1
        21: iadd
        22: putstatic     #6                  // Field count:I
        25: aload_0
        26: monitorexit     //指令退出
        27: goto          35
        30: astore_2
        31: aload_0
        32: monitorexit  //指令退出
        33: aload_2
        34: athrow
        35: return
      Exception table:
         from    to  target type
             5     9    12   Class java/lang/InterruptedException
             5    27    30   any
            30    33    30   any
      LineNumberTable:
        line 10: 0
        line 12: 5
        line 15: 9
        line 13: 12
        line 14: 13
        line 16: 17
        line 17: 25
        line 18: 35
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
           13       4     1     e   Ljava/lang/InterruptedException;
      StackMapTable: number_of_entries = 4
        frame_type = 255 /* full_frame */
          offset_delta = 12
          locals = [ class java/lang/Object ]
          stack = [ class java/lang/InterruptedException ]
        frame_type = 4 /* same */
        frame_type = 76 /* same_locals_1_stack_item */
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

通过上面指令,在第4行执行了指令进入,第26行和第32行执行了指令退出,至于为什么执行两次,是因为一个是正常退出,一个是异常退出。
对象、对象的监视器、同步队列和执行线程之间的关系。该图可以看出,任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

4.CAS

先解释下几个核心概念: 竟态条件与临界区、共享资源、不可变对象、原子操作。

4.1 竟态条件与临界区
public class Demo{
	public int i=0;
	public void incr(){
		i++;
	}
}

多个线程访问相同的资源,向这些资源做了多写操作时,对执行顺序的要求。
临界区:incr方法内部就是临界区,关键部分的代码并发执行,会对执行结果产生影响。
竟态条件:可能发生在临界区内的特殊条件。多线程执行incr方法中i++关键代码时,产生竟态条件。

4.2 共享资源
  • 如果一段代码是线程安全的,则它不包含竟态条件。只有当多个线程更新共享资源时,才会发生竞态条件。
  • 栈封闭时,不会在线程之间共享的变量,都是线程安全的。
  • 局部对象引用本身不共享,但是引用的对象存储在共享堆中。如果方法内创建的对象,只是在方法中传递,并且不对其他线程可用,那么也是线程安全的。
public void someMethod(){
	LocalObject localObject = new LocalObject);
	localObject.callMethod0);
	method2(localObject);

}
public void method2(LocalObject localObject)(
	localObject.setValue("value");
}

判定规则:如果创建、使用和处理资源,永远不会速脱单个线程的控制,该资源的使用是线程安全的。

4.3 不可变对象

如果创建不可变的共享对象来保证对象在线程间共享时不可修改,从而实现线程安全。实例被创建,value就不可修改,这就是不可变性。

public class Demo{
	private int value=0;
	public Demo(int value){
		this.value=value;
	}
	public int getValue(){
		return this.value;
	}
}
4.4 原子操作

原子操作可以是一个步骤,也可以是多个步骤,但顺序不可被打乱。也不可以只执行其中某几步或一步。
现在原子的操作,有CAS、锁。

4.3 什么是CAS?

使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
CAS比较交换的过程可以通俗的理解为CAS(value,old,now),包含三个值分别为:value 内存地址存放的实际值;old 预期的值(旧值);now 更新的新值。当value和old相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值old就是目前来说最新的值了,自然而然可以将新值now赋值给value。反之,value和old不相同,表明该值已经被其他线程改过了则该旧值Oold不是最新版本的值了,所以不能将新值now赋给value,返回value即可。当多个线程使用CAS操作一个变量时,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。
CAS是底层指令,必须要得到硬件的支持。在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。JAVA中CAS也是被native关键字修饰。

4.4 CAS的应用场景

在J.UC包中大量的工具类使用了CAS,后期我们会专门学习J.U.C下并发编程工具类。

4.5 CAS的问题
  1. ABA问题
    因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。Java这么优秀的语言,当然在Java 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。

  2. 自旋时间过长

使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。

  1. 只能保证一个共享变量的原子操作

当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。

5.synchronized的锁的原理(以下内容摘自《Java并发编程的艺术》)

jdk1.6以后对synchronized锁进行了优化,包含偏向锁、轻量级锁、重量级锁; 在了解synchronized锁之前,我们需要了解两个重要的概念,一个是对象头、另一个monitor

5.1 Java对象头

在Hotspot虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充;Java对象头是实现synchronized的锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里。它是轻量级锁和偏向锁的关键。
synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit
Java对象头的长度
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下图:
Mark Word的存储结构
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:
Mark Word的状态变化
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:
Mark Word的存储结构

5.2 Mawrk Word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit)

5.3 锁的升级与对比

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下面会详细分析。

5.3.1 偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

5.3.1.1 偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图演示了偏向锁的初始化流程,线程2演示偏向锁撤销流程:
偏向锁初始化及撤销流程

5.3.1.2 关闭偏向锁

偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:
-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:
-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

5.3.2 轻量级锁
5.3.2.1 轻量级锁加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

5.3.2.2 轻量级锁解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。
轻量级锁膨胀流程图
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

5.3.3 重量级锁

所谓的重量级锁,其实就是最原始和最开始java实现的阻塞锁。在JVM中又叫对象监视器(monitor,它的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高)。这时的锁对象的对象头字段指向的是一个互斥量,所有线程竞争重量级锁,竞争失败的线程进入阻塞状态(操作系统层面),并且在锁对象的一个等待池中等待被唤醒,被唤醒后的线程再次去竞争锁资源。
对象、对象的监视器、同步队列和执行线程之间的关系。

5.3.4 锁的优缺点对比

锁的优缺点对比

5.3.4 锁的完整流程

锁膨胀的完整流程图

5.3.5 总结

所谓的锁升级,其实就是从偏向锁->轻量级锁(CAS自旋锁)->重量级锁,其实说白了,一切一切的开始源于java对synchronized同步机制的性能优化,最原始的synchronized同步机制是直接跳过前几个步骤,直接进入重量级锁的,而重量级锁因为需要线程进入阻塞状态(从用户态进入内核态)这种操作系统层面的操作非常消耗资源,这样的话,synchronized同步机制就显得很笨重,效率不高。那么为了解决这个问题,java才引入了偏向锁,轻量级锁,自旋锁这几个概念。
个人在缕下顺序:

  1. 在没有线程线程进入时,当前是无锁的状态
  2. 如果有一个线程A进入,那么当前锁会升为偏向锁(JAVA头中设置),即A进入不需要验证锁
  3. 如果有两个线程进入(少量线程),会释放偏向锁,并将锁升级为轻量锁(CAS自旋锁)线程栈中记录锁头记录。
  4. 如果多个线程中,有一个线程通过一定次数的自旋还没有拿到锁,那么它会将锁升级为重量级锁(大家都别自旋了,一起阻塞等待吧),锁指针指向monitor。

提出这几个概念优化了什么?

  1. 偏向锁是为了避免CAS操作,尽量在对比对象头就把加锁问题解决掉,只有冲突的情况下才指向一次CAS操作。
  2. 而轻量级锁和自旋锁呢,其实两个是一体使用的,为的是尽量避免线程进入内核的阻塞状态,这对性能非常不利,试图用CAS操作和循环把加锁问题解决掉
  3. 而重量级锁是最终的无奈解决方案。

哈哈哈,看到这里有没有感觉上面那么多字,还不如总结里面的几十个字来的精辟!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值