内涵与表象

synchronized关键字的解释如下:

Java语言的关键字,当它用来修饰一个方法或者一个代码块的时候,能够保证在同一时刻最多只有一个线程执行该段代码。

根据定义,会考虑两个方面的问题:

  1. synchronized是如何保证在同一时刻最多只有一个线程执行该段代码
  2. 保证在同一时刻最多只有一个线程执行该段代码,这又带来什么意义

老司机级别的解释:

Java中的synchronized,通过使用内置锁,来实现对共享变量的同步操作,进而解决了对共享变量操作的原子性、保证了其他线程对共享变量的可见性、有序性,从而确保了并发情况下的线程安全。

原子性

synchronized关键字的一个作用就是确保了原子性

譬如使用count++而言,原先其执行需要三个步骤:

  1. 读取count值
  2. count+1
  3. 写入count 值

其包含了三个步骤,且每次的步骤都依赖于上一次的结果。所以不是原子性的

synchronized(this){
    count++;
}

使用如上的方式,就确保了原子性。因为原子操作是线程安全的,这其实也是我们经常使用synchronize来实现线程安全的原因。

如何实现的原子性?

因为synchronized被编译之后,使用的是monitorenter和monitroexit两个字节码指令,而这两个字节码指令实质上是依赖于操作系统中的互斥锁(mutex lock)实现。通过互斥锁,保证了同一时刻,只有一个线程修改了该共享变量,所以确保了原子性

可见性、有序性

synchronize的确保原子性,其实是从使用synchronize的线程的角度来讲的,而如果我们从其他线程的角度来看,那么synchronize则是实现了可见性、有序性

  1. 可见性:一个线程修改了共享变量的值,其他线程能够立即得知这个修改
  2. 有序性:
    1. Java程序中的天然有序性是指:如果在本线程内观察,所有的操作都是有序的,如果在另一个线程中观察另一个线程,那么所有的操作都是无序的
    2. 前半句是指线程内表现为串行的语义(as-if-serial规则)
    3. 后半句是指指令重排工作内存和主内存同步延迟导致的现象

synchronzied如何保证可见性:

  • JMM规定:一个线程对一个变量进行unlock操作之前,必须先把此变量同步回主内存。

synchronzied如何保证有序性:

  • 锁自身的作用来或者说JMM的规定:一个变量在同一时刻只允许一条线程对其进行lock操作

内置锁

在Java中,每个对象都有一把锁,放置于对象头中,用于记录当前对象被哪个线程所持有。相对于实例数据,对象头属于额外开销,所以被设计的极小来提高效率。对象头中的markword更加体现了这一点,且是非结构化的,这样在不同的锁状态下,能够复用相同的bit位,markword中就有存储锁的信息的部分。

markword
markword

我们知道在Java中Synchronized能够实现的线程的同步,synchronized被编译之后会生成monirotenter和monitorexit两个字节码指令,依赖这两个字节码指令实现线程同步。

Monirtor:可以理解为只能容纳一名客人的房间,而线程可以等比于客人。整个状态的流转可以理解为一个状态机,同一时刻只有一个线程处于Active状态。

monitor
monitor

Every object has an intrinsic lock associated with it. —— The Java™ Tutorials

  1. 普通同步方法

    锁的是当前实例对象

  2. 静态同步方法

    锁的是该类的Class对象

  3. 同步代码块儿

    锁的是括号内的对象

因为synchronized被编译之后,使用的是monitorenter和monitroexit两个字节码指令,而这两个字节码指令实质上是依赖于操作系统中的互斥锁(mutex lock)实现,同时Java线程可以理解为对操作系统中线程的映射,所以每当挂起/唤醒一个线程,都需要涉及到操作系统的内核态内容的切换。属于重量级操作,甚至是有可能切换的时间远远超过于线程本身的运行时间。但是从Java6开始,引入了偏向锁、轻量级锁、重量级锁的锁优化过程,进而优化了其性能。

无锁

定义:不锁住资源,多个线程中有一个能修改资源成功,其他线程会重试,通过CAS实现

偏向锁

定义:偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

依据:在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。

  1. 当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。==在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。==引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

  2. ==偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。==

偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。

轻量锁

定义: 当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

  1. 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
  2. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
  3. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
  4. 如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁

重量锁

互斥锁

可重入:

public class Widget{
    public synchronized void doSomething(){
        ...
    }
}

public class LoggingWidget extends Widget {
    public synchronized void doSomething(){
        sout("...");
        super.doSomething();
    }
}

如果没有可重入性,那么该代码会产生死锁。

可重入的定义:

若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。 —- 维基百科

从设计上讲,当一个线程请求一个由其他线程持有的对象锁时,该线程会阻塞。当线程请求自己持有的对象锁时,如果该线程是重入锁,请求就会成功,否则阻塞。

synchronized拥有强制原子性的内部锁机制,是一个可重入锁。因此,在一个线程使用synchronized方法时调用该对象另一个synchronized方法或调用父类的synchronized方法,即一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的

在Java内部,同一个线程调用自己类中其他synchronized方法/块时不会阻碍该线程的执行,同一个线程对同一个对象锁是可重入的,同一个线程可以获取同一把锁多次,也就是可以多次重入。原因是Java中线程获得对象锁的操作是以线程为单位的,而不是以调用为单位的。

总结

  1. Java中每个对象都有一个内置锁
  2. synchronized使用对象自带的内置锁来进行加锁,从而保证在同一时刻最多只有一个线程执行代码。
  3. 所有的加锁行为,都可以带来三个个保障——原子性可见性有序性。其中,原子性是相对锁所在的线程的角度而言,而可见性、有序性则是相对其他线程而言。
  4. 锁的持有者是线程,而不是调用,这也是锁的为什么是可重入的原因。

References