synchronized关键字

about-synchronized.png

#简介

synchronized关键字解决多线程间访问资源的同步性,保证被其修饰的方法或代码块在任意时刻只能有一个线程执行。

在JDK1.6之前,synchronized是一种重量级锁,效率低下。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换比较慢,时间成本相对较高。在JDK1.6及之后,Java官方从JVM层面对synchronized进行了大量优化,比如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等,这些优化减少了锁操作的开销。

#使用

  • 修饰实例方法:作用于当前对象实例,进入同步代码前需要获取当前对象实例的锁;

    public class synchronizedDemo {
        public synchronized void method() {
            System.out.println("synchronized修饰实例方法");
        }
    }
    
  • 修饰静态方法:给当前类加锁,会作用于类的所有对象实例。访问静态synchronized方法站内用的锁是当前类的锁,而访问非静态synchronized方法占用的锁是当前实例对象锁。

    public class synchronizedDemo {
        public static synchronized void method() {
            System.out.println("synchronized修饰静态方法");
        }
    }
    
  • 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。

    public class synchronizedDemo {
        public void method() {
            synchronized (this) {
                System.out.println("synchronized代码块");
            }
        }
    }
    

synchronized关键字加到static静态方法和synchronized (class) 代码块上都是给Class类上锁。synchronized关键字加到实例方法上是给对象实例加锁。尽量不要使用synchronized (String s),因为JVM中,字符串常量池具有缓存功能。

#优化

JDK1.6对synchronized关键字引入了大量的优化,包括偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等,以此来减少锁操作的开销。

锁主要存在四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,它们会随着竞争的激烈而逐渐升级。锁可以升级但不可以降级,这种策略是为了提高获得和释放锁的效率。

  • 偏向锁:引入偏向锁的目的和引入轻量级锁的目的很像,都是为了在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量 (mutex) 时产生的性能消耗。不同的是:轻量级锁在无竞争条件下使用CAS操作代替互斥量,而偏向锁在无竞争情况下会把整个同步操作都消除掉。

    偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步!

    但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

  • 轻量级锁:轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。另外,轻量级锁的加锁和解锁都用到了CAS操作

    轻量级锁能够提升程序同步性能的依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生CAS操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!

  • 自旋锁和适应性自旋锁:轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的手段。

    如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

    线程自旋是需要消耗CPU的,如果一直获取不到锁,那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,即其它争用锁的线程在自旋超过了限定次数仍然没有获得锁,这时争用线程会停止自旋进入阻塞状态。

    JDK1.6以前采用-xx:UseSpinning来开启自旋锁,JDK1.6及之后默认开启。同时自选次数默认为10次,可以采用-xx:PreBlockSpin进行修改。

    优缺点:自旋锁尽可能地减少线程的阻塞,对于锁竞争不激烈且占用锁时间非常短的代码块来说性能有大幅提升。但如果锁竞争激烈或者持有锁的线程需要长时间占用锁,此时线程自旋的消耗大于线程阻塞挂起操作的消耗,造成CPU资源浪费。

    JDK1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。

  • 锁消除:指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除可以节省毫无意义的请求锁的时间。(字符串连接操作“+”,其实是StringBuffer的append方法操作,这里虽然有锁,但可以被消除掉。)

  • 锁粗化:通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。但是,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化 。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。(比如StringBuffer的很多个append操作,蒋所扩展到第一个append方法之前和最后一个append方法之后,只需加锁一次。)

#与ReentrantLock比较

相同点:

  • 都是用来协调多线程对共享对象、变量的访问;
  • 都是可重入锁,同一线程可以多次获得同一个锁;
  • 都能保证可见性和原子性

不同点:

  • synchronized隐式地获取和释放锁,而ReentrantLock显示地获取和释放锁;
  • synchronized是JVM级别的,而ReentrantLock是API级别的;
  • ReentrantLock可以让等待锁的线程响应中断,而synchronized不可以,使用synchronized时等待的线程会一直等待而不能响应中断;
  • ReentrantLock可以指定是公平锁还是非公平锁,而synchronized只能是非公平锁。ReentrantLock默认是非公平的。
  • ReentrantLock可以通过Condition绑定多个条件。
  • synchronized在发生异常时会自动释放线程占有的锁,因此不会导致死锁现象发生。然而ReentrantLock在发生异常后如果没有使用unLock() 方法释放锁,则很可能造成死锁,因此使用ReentrantLock时要在finally释放锁。
  • 底层实现不一样,synchronized使用的是同步阻塞,是一种悲观锁,而ReentrantLock是同步非阻塞,采用的是CAS乐观锁。