一.概述
在Java并发编程学习(一)——线程一文中,我们详细了解了有关线程的相关话题,其中多次提到了synchronized这个关键字,今天我们就来聊一聊synchronized。
synchronized是java内置的一种用于实现线程间同步的简单、有效机制,是java提供给我们的内置锁。我们可以使用synchronized来修饰方法、代码块,使被修饰的代码一段时间内只能由一个线程进入,从而实现了共享变量被正确的并发访问。
二.相关概念
在进一步了解synchronized方法的使用之前,我们先来看几个有关概念:
线程安全
在《java并发编程实战》中,作者给出了这样的定义:
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
简而言之:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。
竞态条件
看完了线程安全的概念,我们可能会问,线程不安全的情况是如何产生的呢?
一种常见的情况就是:两个线程竞争同一资源时,并且对资源的访问顺序敏感,这种情况叫做存在竞态条件。
举个例子:
public class Counter {
private int number;
public int add() {
return number++;
}
}
在上面的add方法中,我们希望每次调用,number值都会加1,在单线程中执行,完全没有问题,但是在多线程时,结果有时会变得不可预料。
原因就在于,number++
这一行代码在cpu中是由三条指令构成的:
(1)读取number的值放到寄存器;
(2)将寄存器的值+1;
(3)将寄存器的值写入number。
当有两个线程都需要执行add方法时,实际上cpu中就有6条执行需要执行,由于cpu会在不同的线程间切换,而这种切换的时机是未知的,因此6条指令的执行顺序很可能是交替进行的,从而导致了意向不到的结果。
根据我们上面的定义,上面的add方法(准确的说是number++语句)就存在竞态条件。
临界区
导致竞态条件发生的代码区域叫做临界区,上面Counter类中的add方法就是一个临界区。
可见性
可见性是多线程执行中的另一个重要问题,为了保证程序的正确执行,一个线程对共享变量的访问,应该及时被其他线程看到,这叫做内存可见性。
同样以上面的代码为例,如果不加任何的线程同步操作,那么当一个线程修改number时,其他线程并不能及时感知到,反而有可能拿到失效的结果,这就没有满足可见性。
上面我们谈到了线程安全,竞态条件&临界区和内存可见性,之所以提及这些概念,是因为它们是多线程并发执行中不容忽视的问题,也是java多线程机制引入背后的原因,而synchronized就是解决上面问题的一个最简单的方法。下面我们就来看看具体用法。
三.用法
大体来说,synchronized关键字有两种用法:修饰方法或者修饰代码块。
修饰方法
在java中,方法分为对象方法和类方法,synchronized可以修饰这两种方法。
(1)修饰对象方法
public class Counter {
private int number;
public synchronized int add() {
return number++;
}
}
为了使我们前面提到的Counter类线程安全,我们只需要使用synchronized
关键字修饰add方法即可,很简单有没有?加上synchronized关键字后,访问某个Counter对象的所有线程只能互斥的进入add方法。
(2)修饰类方法
public class Counter {
private static int value;
public synchronized static int add() {
return value++;
}
}
修饰类方法同样是在方法声明中添加synchronized
关键字,只是影响的范围不同:修饰静态方法时,将导致线程在访问Counter类的所有对象时,都将互斥的进入add方法,我们验证一下:
class CounterThread extends Thread {
private Counter counter;
public CounterThread(String name, Counter counter) {
super(name);
this.counter = counter;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ",value=" + counter.add());
}
}
public class Counter {
private static int value;
public static int add() {
System.out.println(Thread.currentThread().getName() + " begin add");
try {
Thread.sleep(2000); // 线程sleep时会让出cpu,但不会释放锁
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " has sleep 2000 ms");
return value++;
}
public static void main(String[] args) {
Counter counter1 = new Counter();
Counter counter2 = new Counter();
CounterThread thread1 = new CounterThread("thread1", counter1);
CounterThread thread2 = new CounterThread("thread2", counter2);
thread1.start();
thread2.start();
}
}
在上面的代码中,我们定义了一个线程类,其中有一个Counter类的引用,用于执行add操作。
当前,add方法并没有加synchronized
关键字,线程进入后,会休眠2秒钟,让出cpu让其他线程执行。
执行结果如下:
thread1 begin add
thread2 begin add
thread1 has sleep 2000 ms
thread1,value=0
thread2 has sleep 2000 ms
thread2,value=1
可以看出,thread2在thread1没有执行休眠完成前就进入了add方法,两个线程交替执行。
我们使用synchronized方法修饰add方法,再看看效果:
public static synchronized int add() {
System.out.println(Thread.currentThread().getName() + " begin add");
……
return value++;
}
执行结果如下:
thread2 begin add
thread2 has sleep 2000 ms
thread2,value=0
thread1 begin add
thread1 has sleep 2000 ms
thread1,value=1
可以看出,thread1在thread2退出add方法后,才进入,两个线程是同步的。虽然,两个线程中的Counter引用并不是同一个对象,但是由于add方法是静态方法,因此该类的所有对象的add方法都将被锁定,只有获得锁才可以进入。
修饰代码块
效果如下:
public class Counter {
private int value;
public int add() {
synchronized (this) {
return value++;
}
}
}
被synchronized修饰的代码块需要获得锁才可以进入。效果与修饰方法相同,但是加锁的粒度更细,可以只将存在竞态条件的临界区加锁,其他不需要同步的代码不加锁,这样可以提高多线程的并发执行效率。
synchronized (this)
中,this关键字指代当前对象,意味着,线程在执行该对象时,需要取得锁。
另外,还可以写synchronized (Counter.class)
,将使线程在执行Counter类的所有对象时,都将竞争锁。
无论是修饰方法还是代码块,都存在着对象锁和类锁的概念,对象锁只对执行该对象的线程有效,对不执行该对象的线程无效;类锁则对执行该类的所有对象的线程都有效。
打个比方,我们的家里有一个大门,进到家里每个房间都有自己的小门,大门有一把大锁,用于控制所有人的进入,每个小门的锁只控制进入该房间的人。大门的锁相当于类锁,房间的锁相当于对象锁,进入家里的人就是一个个的线程。
四.实现
看起来,synchronized的使用非常简单,那么背后的实现原理是什么样的呢?
java虚拟机是通过Java对象头和monitor这两者结合来实现synchronized同步的。
java对象头
jvm堆中创建的每个对象都包含以下几个区域:
对象头:记录了对象的hash码、锁信息、分代信息等;
实例变量:存储了对象中的属性信息;
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。为了字节对齐,有时需要做字节填充
我们重点看对象头部分,对象头占2个字的内存空间(数组对象占3个字,多出来的一个字记录数组长度),包含以下两个部分信息:
Mark Word:存储对象的hashCode、锁信息或分代年龄或GC标志等信息。
Class Metadata Address:类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。
考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。32位的jvm,随着对象的状态不同,可能的数据结构如下:
其中,轻量级锁和偏向锁是jdk 6.0新增的,之前只有重量级锁,当锁状态为重量级锁时,其中指针指向的是monitor对象,每个对象都存在着一个monitor对象与之关联。
monitor
在jvm中,monitor是由ObjectMonitor实现的(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的),其主要数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; // 指向持有该对象的线程
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
在了解了对象头和monitor之后,我们再来看synchronized加锁的实现原理:
- 当一个线程想要进入synchronized代码块或者被synchronized修饰的方法时,就会改变对象头中锁状态,并设置monitor指针指向monitor对象;
- 所有想要获得锁的线程首先会被放到_EntryList列表中;
- 先判断monitor对象中的_count字段是否为0,如果为0,则说明当前对象没有被其他线程占用,将_count加1,,将_owner指向当前线程;
- 当线程执行完毕,需要释放锁时,再将_count减1,将_owner置为null;
- 在某个线程已经取得锁,并且还没有释放时,如果有其他线程尝试获得锁,就会被添加到_EntryList列表中,被阻塞,直到锁被释放再根据某种策略允许阻塞的线程进入。
至于类锁,个人理解加锁过程中操作的是类对应的Class对象。
五.jvm对synchronized的优化
由于monitor是依赖于操作系统底层的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。在jdk 1.6之前,synchronized就属于这种重量级锁,需要经常进行用户态到核心态的切换,效率不高,因此1.6中对synchronized进行了优化,引入了偏向锁、轻量级锁、自旋锁等概念。
偏向锁
经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
如下图所示:
自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
我们上面说到,锁一共有四种状态:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它们会随着竞争情况逐渐升级,但是不能够降级。
锁升级的过程大概是这样的,刚开始处于无锁状态,当线程第一次申请时,会先进入偏向锁状态,然后如果出现锁竞争,就会升级为轻量级锁(这升级过程中可能会牵扯自旋锁),如果轻量级锁还是解决不了问题,则会进入重量级锁状态,从而彻底解决并发的问题。
参考资料:
- 《java并发编程实战》
- 竞态条件与临界区
- 深入理解Java并发之synchronized实现原理
- Java中的锁机制 synchronized & 偏向锁 & 轻量级锁 & 重量级锁 & 各自优缺点及场景 & AtomicReference
本文已迁移至我的博客:http://ipenge.com/25781.html