Synchronized是Java内置的可重入锁(外层方法使用锁,那么内层依然可以使用,并且不会发生死锁)。Synchronized作用于对象而非引用,对于同一时刻多线程访问,仅允许一条线程访问临界资源。它的作用有:
(1). 原子性
(2). 可见性
(3). 有序性
Synchronized作用域
Synchronized的加锁方式有:
(1). 作用与普通方法声明。这种方式锁的是当前实例对象。
public synchronized void sync();
(2). 作用于静态方法声明。这种方式锁的是当前类实例
public static synchronized void sync();
(3). 作用于代码块,锁实例对象。
private Object lock = new Object();
public void sync(){
synchronized(lock){
// ...同步代码块
}
}
(4). 作用于同步代码块,锁某个类实例。
public void sync(){
synchronized(Object.class){
// ...同步代码块
}
}
对于第2种和第4种方式,由于锁的是类实例,锁粒度太大,所以我们在开发过程中应当避免使用这种方式,才不会对系统性能造成太大的影响。
Synchronized核心原理
MESA模型
synchronized采用的是管程技术来解决并发问题。那么什么是管程呢?在JVM内部有这么一个内置对象Monitor,一般称Monitor对象为监视器,也可以称为管程。所谓的管程,就是共同管理共享变量的访问,使多线程的访问达到并发安全。
在并发编程中,要解决的核心问题是:
(1). 互斥。在同一时刻只有一条线程能访问临界资源。
(2). 同步。多线程间如何进行通信、协调。
那么怎么理解管程呢?其实我觉得管程只是并发编程的一个概念模型,具体实现各有稍微不同,不过总体的原理大致都是一样的。
管程中一般都存在两个队列,一个是线程进入同步代码块的队列,我们暂时称为入口队列,主要用来实现线程间的互斥。另一个队列我们称为条件队列,主要用来解决线程间的同步问题。大概模型如下:
(1). 当多个线程访问临界资源时,只允许一个线程进入临界资源。其他线程进行入队操作并阻塞。
(2). 进入到管程中时,如果发现不满足条件变量,则进入条件等待队列。举个例子:
# 往阻塞队列执行入队操作,如果不满足条件变量,则线程阻塞。
void put(E e){
synchronized(lock){
while (notFull())
lock.wait()
enqueue(e);
}
}
注意到我上面用的是个while循环而不是if判断语句,为什么要使用while呢?
因为当线程被唤醒时,有可能依旧不满足条件变量,此时,就需要再对条件变量进行判断。
(3). 当线程被执行例如notify()或notify方法被唤醒时,从条件等待队列中出队,并需要重新获取锁,否则进入入口等待队列中。获取锁时,继续从上一次执行条件变量判断的位置继续执行。
上述的这种管程模型被称为MESA模型,Java内置锁Synchronized就是基于这种模型实现的。但Java也对这种模型的实现做了减法,例如Synchronized中只有一个条件变量和一个条件等待队列。而JDK封装的Lock也是基于MESA模型的实现,但他提供了多条件变量和多条件等待队列。
Synchronized锁升级过程
synchronized在jdk1.6版本之前,只支持重量级锁。随着jdk1.6版本的优化,synchronized不再那么的沉重,为了降低重量级锁带来的释放锁和加锁的性能消耗,引入了偏向锁和轻量级锁。
对象头
要理解synchronized的锁升级过程,我们必须熟悉Java对象头,synchronized的锁升级过程依赖于对象头。我们先来熟悉Java对象头。
Java对象可分为三个区域:对象头(Header)、实例数据(Instance Data)、对象填充(Padding)。而对象头主要是由Mark Word和Klass Point指针组成的(注意这里是Klass不是Class,KlassPoint是对象指向类实例的指针)。其中,Mark Word是实现偏向锁和轻量级锁必不可少的,存储着如对象HashCode、偏向线程ID、GC分代年龄、锁标志位、轻量级锁记录指针等。Mark Word会随着程序的运行而发生变化。对于数组对象,对象头占用3个字宽,对于非数组对象,对象头占用2个字宽。
(1个字宽等于2个字节)
偏向锁
大多数情况下,进入临界资源的线程总是只有一个,这时候如果频繁的加锁或释放锁,那么会增加无畏的性能消耗。经过JDK1.6优化之后,引入了偏向锁。偏向锁的核心思想是,一个线程获取锁时,那么它便处于偏向状态,此时Mark Word为偏向锁结构,即锁标志位为01,且记录当前获得偏向的线程ID。这样,当线程再次请求获取锁时,如果Mark Word中的线程ID和自己一致,便可直接获取锁,无需申请。JVM默认为我们开启偏向锁,可使用一下参数进行开关:
(1) 开启偏向锁: -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
(2) 关闭偏向锁: -XX:-UseBiasedLocking
偏向锁升级步骤:
(1) 检查Mark Work中的是否偏向位、锁标志位和偏向线程ID
(2) 通过CAS将偏向线程ID修改为自己的线程ID。
(3) 如果成功,则获取锁,进入同步代码块
(4) 如果失败,则等待获取锁的线程到达安全点并暂停线程,然后判断线程是否已经退出同步代码块。如果是,则通过CAS将偏向锁ID替换位自己的线程ID。如果不是,则升级为轻量级锁。
轻量级锁
当偏向锁升级为轻量级锁之后,Mark Work的结构变为轻量级锁结构,存储指向获取锁的线程栈指针。且参与锁竞争的线程会将Mark Work拷贝到栈中。
轻量级锁的升级步骤:
(1) 拷贝Mark Work到栈中,存储区域称为LockRecord。
(2) CAS修改Mark Work中指向线程栈的指针,将指针指向自己的线程栈。
(3) 如果成功,则获取锁。
(4) 如果失败,则进行自旋获取锁。
(5) 当自旋锁自旋到一定的阈值时,如果还获取不到锁,会认为锁繁忙并将锁升级为重量级锁。
既然时自旋锁,那么如果自旋次数过多,对性能的消耗也是影响极大。JVM1.6后轻量级锁使用了自适应自旋锁,会根据监控数值判断自旋次数。
下图为锁升级过程:
线程逃逸分析
逃逸分析是JIT即时编译时对锁的一种优化手段。通过对上下文的扫描,发现当前的同步代码块并
不会存在共享资源的竞争,则进行锁消除。例如
public void test(){ StringBuffer buffer = new StringBuffer(); buffer.append("xi"); }
StringBuffer是线程安全的字符串操作类,他的append方法使用了synchronized来进行同步操作。但这是buffer是一个局部变量,并不存在多线程竞争。所以经过JIT即时编译之后,会进行锁消除,来减少加锁和释放锁的消耗。
开启逃逸分析:
开启逃逸分析: -XX:+DoEscapeAnalysis
表示开启锁消除:-XX:+EliminateLocks
逃逸分析还有其他的优化。如果一个对象指针不会逃逸,则可能对象由堆分配转换为栈分配。