synchronized
关键字解决的是多个线程之间访问资源的同步性,synchronized
关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
在 Java 早期版本中,synchronized
属于 重量级锁,效率低下。为什么呢?因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock
来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
Java 6 之后 Java 官方对从 JVM 层面对 synchronized
较大优化,所以现在的 synchronized
锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。所以目前锁状态一种有四种,从级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁。
本文只简单说一下synchronized锁升级过程,记录一下学习的笔记,synchronized其他相关知识本文不再多做赘述,等有时间再补充。
首先我们知道在JVM 中,对象实例在堆内存中被分为了三个部分:对象头、实例数据和对齐填充。其中 Java 对象头由 Mark Word、指向类的指针以及数组长度三部分组成。
线程在同步的时候是获取对象的monitor,即对象的锁。Java对象天生就是一个Monitor,当monitor被占用,它就处于锁定的状态。
每个对象都与一个监视器关联。且只有在有线程持有的情况下,监视器才被锁定。但对象的锁到底是什么呢?其实就是类似对对象的一个标志,即存放在Java对象的对象头。
Mark Word 记录了对象和锁有关的信息。Mark Word 在 64 位 JVM 中的长度是 64bit,我们可以一起看下 64 位 JVM 的存储结构是怎么样的。如下图所示:
举例子模拟锁的升级过程:
Step1
刚开始一个对象object,刚刚new出来,没有任何线程给它加锁。
可以用BiasedLockingStartupDelay参数设置是否启动偏向锁(-XX:BiasedLockingStartupDelay=0,立即启动偏向锁)。
如果偏向锁打开,默认是匿名偏向状态,new出来的对象,默认就是一个可偏向匿名对象101。
为什么一上来还没有加任何锁的时候就是偏向锁呢 ?这里是匿名偏向,虽然还没有任何线程在该object上加锁,但是java默认给它加了一个匿名偏向,就相当于一个锁前的准备状态。
——>
Step2 偏向锁
来了一个线程A,A给object上了个锁,上锁指的就是把object的对象头里的MarkWord的线程ID改为自己线程 ID 的过程。因为只有A一个线程,没有发生竞争,所以object加的是偏向锁。
——>
Step3 轻量级锁
这时候第二个线程B来了,B也想给object上锁,于是两个线程发生了锁竞争(如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争)。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁升级到标准的轻量级锁(自旋锁),使锁进入竞争的状态。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。
在升级轻量级锁之前,JVM会先在当前发生竞争的两个线程的栈帧中开辟一块单独的空间,创建用于存储锁记录的空间即将对象头中用来标记锁信息相关的内容封装成一个java对象放入当前线程的栈帧中,里面保存指向对象锁MarkWord的指针,这个对象称为LockRecord。
A线程和B线程都生成了LockRecord,然后两个线程都尝试通过CAS操作在object对象头中的MarkWord中保存指向自己锁记录(lockrecord)的指针。如果某个线程成功把对象头中MarkWord设置为指向自己这个线程的LockRecord的指针,说明这个线程成功获取锁。
这个获取锁的操作,详细过程其实就是通过CAS修改对象头里的锁标志位,先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”(比较并设置是原子性发生的),这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己,也就是在object对象头中的MarkWord中保存指向自己锁记录(lockrecord)的指针,MarkWord的线程ID改为自己线程 ID 。
如果当前锁标志位不是“释放”,则此线程获取锁失败,没有抢到锁就会进行自旋,即不停地循环判断锁是否能够被成功获取,不断的循环进行CAS操作直到能成功替换信息。所以轻量级锁又叫自旋锁。
什么是CAS操作,简单说一下,在两个线程获取轻量级锁的过程中,假设B线程先读取锁的信息发现当前锁标志位是“释放”,此时锁的信息还未被修改,于是就想把当前锁的持有者信息修改为自己,在这个改的过程中再次读取object对象头中锁的信息,与之前所读到的信息进行比较,看是否发生变化,如果发现跟第一次读取的时候值不同,锁信息都变为了A的(说明线程A抢到了),就放弃此次修改,线程B就会再次读取当前锁的值,重复这个操作进行自旋。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。
如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。
这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
——>
Step4 重量级锁
如果竞争加剧,很多线程抢一把锁,有线程超过10次自旋(-XX:PreBlockSpin参数可调),或者自旋线程数超过CPU核数的一半,此时锁就会再次膨胀,升级为重量级锁。
JDK 1.6之后,自旋锁可以进行适应性升级,加入自适应自旋 Adapative Self Spinning ,JVM自己控制,自动升级到重量级锁。
重量级锁需要向内核态申请资源,以上操作都是在用户态,执行效率高,内核态申请的重量级锁有一个队列,所有的线程都在里面排着,那些抢不到锁的线程、一直自旋的线程就会挂起,进入等待队列,保持wait阻塞状态,不消耗资源,然后等前面获取锁的线程释放了锁之后,再开启下一轮的锁竞争,而这种形式就是我们的重量级锁。等待操作系统的调度,然后再映射回用户空间。
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
### 锁的降级
锁通常只在 GC 时才会发生降级,这时通常是被 GC thread 访问
### 锁的消除 (lock eliminate)
public void add(String str1, String str2) {
StringBuffer sb = new StringBuffer(); sb.append(str1).append(str2);
}
sb 这个引用只有在 add 方法内部使用,是栈私有的,因此sb 是不可能被共享使用的资源,因此 vm 会自动消除 StringBuffer 内部的锁。
### 锁的消除 (lock coarsening)
public void test(String str) {
StringBuffer sb = new StringBuffer();
for (int i=0; i<100; i++) {
sb.append(str);
}
}
在上面的例子中,jvm会对锁优化,将加锁的范围移到for循环外面就是锁粗化。