synchronized专场

synchronized和lock都是java中常用的同步机制。但不得不说,JDK官方爸爸对于synchronized这个小儿子的偏爱明显超过了lock——即使我们说synchronized会笨重,会产生上下文切换的线程开销,但JDK为了解决这个问题给它专门做了一系列的优化,目前的主流也是使用synchronized进行同步。

synchronized和lock的区别

我们这里说的lock,大体上是指的由AQS而衍生出来的ReentrantLock和ReentratReadWriteLock。lock相比起synchronized来,主要的不同有以下几点:

  • 等待可中断: lock机制被挂起的线程,在等待期间是可以通过interrupt自行选择 放弃等待而返回的。而synchronized却不能。

  • 公平锁: lock有公平锁的实现机制,而synchronized没有。

  • 锁绑定多个条件: 这一点很好理解,lock自带条件变量,可以new好多个条件变量,用来建立多个等待队列;但synchronized无法做到这一点,要使用wait()和notify()时只能锁定一个对象。

  • 能否安全释放: 这一点也很重要,lock通常使用try-catch-finally结构的原因就是要把unlock()写在finally里面,不然就有可能落得死锁的下场。而synchronized是可以自行释放的。

在JDK5的时代,synchronized的性能在大规模并发下远远低于lock。但是按照官方的说法,这不是因为lock有多么优越,而是因为synchronized有着很大的优化空间。在JDK6,synchronized得到了一系列的优化,其中有适应性自旋等待、锁消除、锁粗化、偏向锁、轻量级锁、重量级锁等等。

synchronized的锁优化

来看一看JDK爸爸对synchronized的一系列优待:

  • 适应性自旋:其实在大多数情况下,共享资源被一个线程“占着不放手”的情况只会持续短短的一段时间,这样的话就可以让后一个线程“先等一会”,而不是盲目地直接挂起。这就是适应性自旋——执行一个循环(自旋),限定循环的次数,如果在有限次数以内得到了锁,就不用再被挂起和唤醒了。可以使用-XX:+UseSpinning参数来开启自旋,在JDK 6中就已经改为默认开启了;自旋次数默认为10次,可用参数-XX:PreBlockSpin来自行更改。JDK6中引入了适应性的自旋,也就是说:如果在同一个锁对象上,自旋等待刚 刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。

  • 锁消除:锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。(有人会问为什么明明不需要同步的代码还要加锁?那是因为虚拟机优化的底层,有可能会在你完全意想不到的地方给你加上锁)
    看一个例子:

//看起来完全没有同步
public String concatString(String s1, String s2, String s3) { 
	return s1 + s2 + s3; 
}

实际上它的底层是这样的:

public String concatString(String s1, String s2, String s3) {    
	StringBuffer sb = new StringBuffer();    
	sb.append(s1);   
	sb.append(s2);    
	sb.append(s3);    
	return sb.toString(); 
}

当然以上代码仅限于JDK5之前,JDK5及以后都是StringBuilder了。(好像找到了为什么要用线程不同步的StringBuilder的原因?)这里只是拿它来举一个例子。
这里上面的StringBuffer的每一个append操作都有synchronized加锁,而jvm在判定了sb引用不会逃逸到线程外去后,直接将锁取消。

  • 锁粗化:还是上面的append当例子,如果是一系列的append,那么只需要加锁一次(从第一个到最后一个的范围)就可以了。

  • 偏向锁、轻量级锁、重量级锁
    这三个锁要从HotSpot虚拟机对象的内存布局开始说起。HotSpot虚拟机的对象头(Object Header)分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age) 等。这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特,官方称它为“Mark Word”。这部分是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。
    在这里插入图片描述
    当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。这就是偏向锁。偏向锁是为了解决“在大多数情况下,synchronized锁定的代码块其实根本就只有一个线程在运行”这样的情况。

而一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。这个时候再看,如果对象未锁定,就对其使用轻量级锁定;如果对象已锁定,则升级为轻量级锁。

轻量级锁是为了解决“虽然有好几个线程在用这个对象,但它们之间不存在竞争”这样的情况。它是这样操作的:在当前线程的栈帧中建立一块名为锁记录(lock record)的空间,然后尝试用CAS操作把对象头的Mark Word更新为指向这块空间的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的 最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。

如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟 机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了(可重入锁),否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志 的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。重量级锁就是我们常说的synchronized最普通状态下的锁,那个操作非常重量级并且消耗上下文切换时间的锁。

那么重量级锁的底层又是怎样的呢?这就要提到一个monitor机制,中文翻译过来叫做“管程”(操作系统的噩梦?)或者“监视器”。它实际上是一种同步原语,是一种操作系统并不支持的同步原语,其实质是基于编程语言的。它为我们提供一个这样的接口:同一个时刻,只有一个进程/线程 能进入 monitor 中定义的临界区,这使得 monitor 能够达到互斥的效果。无法进入 monitor 临界区的进程/线程,它们应该被阻塞,并且在必要的时候会被唤醒。

monitor 机制需要几个元素来配合,分别是:
1、临界区
2、monitor 对象及锁
3、条件变量以及定义在 monitor 对象上的 wait,signal 操作。

其实到这里已经非常明显了,在java中,被 synchronized 关键字修饰的方法、代码块,就是 monitor 机制的临界区。

在字节码的层面上,synchronized关键字经过Javac编译之后,会在同步块的前后分别形成 monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。总之,synchronzied 需要关联一个对象,而这个对象就是 monitor object。任何一个 Java 对象都可以作为 monitor 机制的 monitor object。

根据《Java虚拟机规范》的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行 monitorexit指令时会将锁计数器的值减一(可重入)。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

这里有一个ObjectMonitor模式用于底层实现。它的基本过程如下图所示:线程先是进入Entry Set区,当它拿到了锁时,就进入Owner区。而如果它被挂起,就会进入Wait Set区,在Wait Set区拿到了锁的线程又可以回到Owner区来。或者如果它彻底释放锁,就会退出这个循环。
在这里插入图片描述
我们平时说“一个线程获取一个对象的锁”,其实就是这个线程进入了在这个对象上的Owner区。更加详细的内容也可以参考这一篇博客

以上就是几种锁优化的基本实现。在这一套优化操作过后,synchronized和lock的性能其实已经相差无几。目前的情况是,比较复杂的业务情况(需要用到好几个条件变量的)就用Lock,而普通的情况synchronized就够了,所以还是synchronized用得比较多。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值