深入浅出Java并发编程之synchronized实现原理


在Java多线程编程中,synchronized可谓是元老级人物。
在JDK1.6之前,synchronized一直被认为是重量级锁,这里首先介绍一下为什么要称为重量级锁。

了解重量级与轻量级含义

简单来说,轻量级指这把锁的实现机制是完全是在用户态完成的,不涉及到操作系统来对线程进行调度管理。
重量级则反之,用户程序无法完成对于线程的同步管理,因此在用户态中用户程序进行了系统调用,程序陷入内核态,由操作系统来调度管理这些线程,(例如:linux系统中提供mutex_lock互斥锁来实现同步,同一时间只有一个线程能持有互斥锁,这就是操作系统提供的锁),在陷入内核态后,操作系统会接管这些线程,对它们进行阻塞、唤醒、调度等管理。

  • 思考:为什么在用户态实现的就是轻量级锁,而调用操作系统实现的就是重量级锁呢?

答:因为在java中开启的线程和操作系统的线程是一一对应的,线程是系统调度的最小单位。
例如一个四核四线程的cpu,就是一个核上可以跑一个线程, 一共可以运行4个线程,每个线程在核上有自己独立的缓存,线程对变量的读写都是通过缓存的。因此,在两个线程同时写一个变量时,极有可能发生冲突,例i=0, A,B都读到i=0,因此对i进行+1操作,所以i被两个线程加了两次,还是等于1。所以为了线程安全,引入了加锁的方法:
还是这个场景:A,B线程同时在运行,A、B两个线程都想读出一个共享变量i,执行i++.

  • 如果是用户态的轻量级锁,A先获得锁,执行i++,将i写回内存,这个期间,B就死循环等待;等到A释放锁之后,B获取到锁,读取最新的i,执行了i++。整个过程,B虽然等待了一会,但也还在运行。
  • 如果是内核态的重量级锁,A先获取到锁,执行i++。这期间B因为没有获取到锁,于是,操作系统使B线程进入阻塞状态,(因为让它空转是浪费cpu的)。当A释放锁后,系统再将B线程放到就绪状态,等待下一次线程调度再运行它。

通过分析这个过程,你会发现,轻量级锁和重量级锁其实各有优缺点。

  • 轻量级在资源竞争少的情况下更高效,但如果资源竞争大的情况下,面临着浪费CPU的问题。
  • 重量级锁在竞争少的情况下显得更笨重,但如果资源竞争大的情况下,它能保证CPU的吞吐量。

因此,在java1.6及以后版本中,sun公司对synchronized关键字做了非常多的优化,昔日的重量级锁转型为轻重兼顾的全能高手。

synchronized实现原理

这里我按照自己的理解先通俗地讲一遍synchronized大概的实现思路。
举例一个我们在大学很容易碰到的场景:占座。其实占座也算是一个对资源的争抢,当一个座位已经被别人占了的话,你就必须要另寻其它座位了。
现在你就想要教室里一个特定的座位,只有这个座位能满足你的要求(你体内的程序和你说,它就要这个,占不到这个座位就要毁灭你)。此时我们来分类讨论下你面临的情况。

  1. 座位还没被占。你非常高兴,赶紧拿写了自己名字的本子放在桌上。(偏向锁:贴上自己的名字)
  2. 座位上已经有别人的书了。这时你决定就要硬等那个占座的人回来,不断地检查那个人有没有从这张桌子上把他的书拿走,你再占上这个桌。(轻量级锁,也叫自旋锁,一直自旋等待锁)
  3. 你等到天都黑了,那个人还是不放掉这张桌子。这时你请来老师评理,老师和你说,你先回宿舍睡个觉,等这桌子空了,我就通知你。(重量级锁,OS来把还在等待的线程阻塞,不让它们空转了)
    (例子中肯定有形容不准确的地方,请大家领会精神即可)

synchronized锁的实现流程大概就是这三步:偏向锁->轻量级锁->重量级锁
synchronized关键字锁的对象有三种

  • synchronized修饰静态同步方法:此时锁是这个类对象(xx.class)
  • synchronized修饰普通同步方法:此时锁是这个类的实例对象(this)
  • synchronized修饰同步代码块:此时锁是某个指定的对象(如:Object o)

所以问题来了,线程怎么在锁对象上贴上自己的名字以占到这把锁呢?
所以我们要了解Java中对象的内存布局。

Java中对象的内存布局

一个java对象分为这四个部分

长度内容内容
32/64bitMarkWord存储对象的hashcode,锁信息,GC信息
32/64bit类指针存储指向当前对象类的指针
若干bit成员变量存储对象中各种变量值等信息
若干bitpadding对齐位,将对象填充位8B的整数倍

如表所示,MarkWord(又叫对象头)就是用来存储锁信息的。
MarkWord存储结构如表所示:
MarkWord存储结构
线程会根据锁对象markword中锁标志位来判断是否已被其它线程加锁。

引入基础知识后,让我们来真正进入锁中一探究竟:

锁膨胀流程

synchronized共有4种锁的状态:无锁、偏向锁、轻量级锁、重量级锁,锁通常只能升级,降级情况极少(GC垃圾回收中Stop the world时可以降级)。

偏向锁

  • 获取偏向锁:如果一个线程在尝试获取一个锁对象时,发现它处于无锁状态,这时它赶紧把自己的线程ID写到MarkWord中。这样他就获得了一把偏向锁。
    偏向锁究竟为什么叫偏向呢?答案就是:它偏向第一个获取它的线程。只有第一个获取它的线程才能拥有这把偏向锁。(所以在JVM打开偏向锁时,对象在被创建出来时就偏向创建它的线程,也称为匿名偏向)
  • 偏向锁的撤销:只有当发生线程竞争获取锁时,偏向锁才会撤销。撤销操作发生在一个全局安全点,在这个安全点上,除了GC,没有正在执行的用户线程。这时它会检查markword中记录的持有偏向锁的线程,是否还在活动状态。
    撤销偏向锁只有两种可能:改成无锁,或改成轻量级锁。
    撤销流程如下图所示。可以看到,偏向锁的撤销还是比较耗时的,因此,在已知存在较多线程竞争的情况下,关闭偏向锁可能对程序执行来说更加高效。
Created with Raphaël 2.2.0 开始 到达全局安全点, 检查markword, 获取线程ID 线程是否还处于 存活状态? 检查该线程的栈 栈中是否有锁记录? 修改markword为轻量级锁 (设置为指向栈中锁记录的指针) 结束 修改markword为无锁状态 或标记markword不适合偏向锁 将markword设置为 无锁状态 yes no yes no

轻量级锁

当其他要申请锁对象的线程看到锁对象的偏向锁标志时,他就开始对对象头进行CAS操作。CAS全称:CompareAndSet或者CompareAndSwap,意为先比较再交换。

CAS

CAS是一种在用户态解决线程资源竞争的方法。还是以i++举例,A、B两个线程都想对共享变量i进行i++操作,i初始值为0;不用加锁、解锁,在用户态如何实现呢?
答:CAS!
A和B先读i初始值为0,然后进行一个原子操作cas:先比较i是否为0,如果是,就进行i=1赋值操作;如果否(说明被其它线程改过了),读出了i=1,这显然 i!=0, 于是更新比较值,再进行cas(1,2),判断i==1?等于就给i赋值为2。这就叫自旋,是一种非常乐观的心态,它相信总有一天它能自旋到它的值。(所以这种实现方法在数据库中也叫乐观锁)。

Created with Raphaël 2.2.0 开始 读i 比较i=我记忆的样子吗? 更新i的值 结束 yes no

注意图中比较步骤和赋值步骤是一个不可分割的原子操作。在硬件中利用lock总线来实现。

CAS实现轻量级锁

  • 获取轻量级锁:线程在看到对象头中的偏向锁后,开始了自旋,不断地去判断对象头中锁状态是否为无锁,是的话就赶紧占住锁,将锁状态改为轻量级锁,把自己的线程信息写进MarkWord,并且将这个锁记录放入自己的线程栈中。如果获取不到锁,就不断地空转、判断。(总结:线程用CAS不断试图修改对象头,直到拿到锁对象)
  • 释放轻量级锁

重量级锁

在JDK1.6之前,默认规定线程自旋次数超过10次,或者正在自旋的线程超过CPU核心数的1/2,锁就自动膨胀为重量级锁。OS将正在等待的线程阻塞,直到锁被释放再唤醒这些线程。至于自旋次数、或者等待线程数,可以通过JVM参数来对它进行 调优
在JDK1.6之后,JDK升级为自适应自旋锁。可以根据实际情况来对参数进行自动调整。

流程一览

锁升级过程

synchronized各种锁的优缺点

三种锁的优缺点

synchronized的小知识

偏向锁启动时间

偏向锁默认在Java1.6和1.7是启动的,但默认延迟4s。这是为什么呢?
答:因为Java程序在启动时,JVM要开启的线程很多,这些线程之间存在大量竞争(例:类加载),因此延迟4s再开启偏向锁,以保证程序效率。偏向锁撤销消耗大,不适合多线程竞争的情况。

synchronized是否支持锁重入

  • 支持。例如在父类中定义了一个sychronized的XXX方法,子类要重写该方法时,调用super.XXX(),这时,如果不支持锁重入,那么就要重写大量冗余代码,这显然不满足面向对象编程原则。
  • 如何实现synchronized锁重入?线程已经获取了一把锁,该怎样再次获取到这把锁呢?用锁记录即可。线程获取到锁后,将当前对象头的锁记录放到线程栈中,释放锁时,就从栈中弹出锁记录,然后重写对象头。如此反复进行,直到线程释放所有在持有的锁。

和Object.wait(),Object.notify()配合实现线程通信

与synchronized配合实现线程通信的就是Object.wait(), Object.notify(),因此,不要单独使用这两个方法,单独使用这两个方法可能会发生线程永久阻塞的情况,从而会抛出IllegalMonitorStateException异常。
比如如果想使线程进入阻塞时,可以使用Thread的yield()方法或LockSupport提供的park()方法(该方法由Java的本地方法库的Unsafe提供),而不要使用wait()。

StringBuffer的实现

StringBuffer继承了AbstractStringBuilder,所有方法基本与StringBuilder相同,它们的区别在于StringBuffer的所有方法几乎都加了synchronized, 因此StringBuffer是线程安全的。从单线程的角度考虑,StringBuilder效率更高;从多线程的角度考虑,StringBuffer才能满足线程安全。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值