Java关键字----Synchronized
引入
在学习Synchronized关键字之前,我们先看一下下面这个代码示例:
public class SynchronizedDemo implements Runnable {
private static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new SynchronizedDemo());
thread.start();
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + count);
}
@Override
public void run() {
for (int i = 0; i < 1000000; i++)
count++;
}
}
在上面这个代码示例中,我们开启了10个线程,每个线程都让count累加了1000000次,如果正常操作的话,最后的结果就是10*1000000=10000000。但是我们运行多次,发现结果都不是这个数字,而且每次运行的结果都不一样。这是为什么呢?
- 造成以上问题的原因是java内存模型中的JMM设计,主要集中在主内存和线程工作内存而导致的内存可见性问题以及重排序问题。每个线程在运行时都有自己的栈空间,每个线程依次去主内存读取这个共享变量,然后对其进行操作,操作之后不知道何时会把操作后的变量传回到主内存,所以上面代码每次的运行结果都不同且不到10000000。那么我们如何让每次线程操作的数都是主内存中的最新数字呢?这时就要提及到Synchronized关键字。java关键字synchronized具有使每个线程依次排队操作共享变量的功能。
一.Synchronized的特性
synchronized是利用锁的机制来实现同步的。下面synchronized的特性也就是该关键字在并发编程中能保证的特性。
Synchronized的原子性
- 原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程当中不会被任何因素打断,要么就都不执行。
在java中,对基本数据的读取和赋值操作是原子操作,即这些操作是不可被中断的,要么执行,要么不执行。但是像i++、i+=1等操作字符就不是原子性的,它们是分成读取、计算、赋值几步操作,原值在这些步骤还没完成时就可能已经被赋值了,那么最后赋值写入的数据就是脏数据,无法保证原子性。被synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断,即保证了原子性。
Synchronized的可见性
- 可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程来说都是可见的。
synchronized关键字对一个资源加锁后,一个线程如果要访问该类或对象,就必须先获取它的锁,而这个锁的状态是可以被其他任何线程可见,当当前线程对资源操作完成后,等到它释放锁的那一刻,会先将对资源的修改写入刷新到主内存中,然后再释放锁资源,保证资源的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待该锁的释放。
Synchronized的有序性
- 有序性指程序执行的顺序按照代码先后执行。
Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。
Synchronized的可重入性
我们知道当一个线程去申请操作一个由其他线程持有的对象锁的资源时,会处于一个阻塞状态。但是当一个线程再次请求自己持有对象锁的临界资源时,这种情况是ok的,这属于重入锁。通俗一点说就是一个线程拥有了锁仍然可以重复申请当前锁资源。
可重入性是类对象中的monitor中的锁计数器来实现的,我们下面会讲到。
二.Synchonized的不同用法
在java代码中使用synchronized可是使用在代码块和方法中,根据Synchronized用的位置可以有这些使用场景:
如图,synchronized可以用在方法上也可以使用在代码块中,其中方法是实例方法和静态方法分别锁的是该类的实例对象和该类的对象。而使用在代码块中也可以分为三种,具体的可以看上面的表格。这里的需要注意的是:如果锁的是类对象的话,尽管new多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系。
三.Synchronized的底层实现
在理解锁实现原理之前先了解一下Java的对象头和Monitor。在java中每个对象都有一个Monitor(监视器),在JVM中,对象是分为三部分组成的:对象头(Object Header)、实例数据、对其填充.如下图所示:
- 实例变量存放类的属性数据信息(就是成员属性的值),包括父类的属性信息;
- 填充数据不是必须部分,由于虚拟机要求对象起始地址必须是8字节的整数倍,对齐填充仅仅是为了使字节对齐。
关于对象的对象头,我们要细说一下:
Java对象头:
- 对象头是我们需要关注的重点,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。在同步的时候是获取到对象的monitor,也就是获取到对象的锁。对象的锁无非就是类似于对象的一个标志,这个标志就放在java的对象头中。java对象头中的MarkWord里面默认存放对象的HashCode,分代年龄和锁标记位等等(如上图)。
我们知道锁也分不同状态,JDK6之前只有两个状态:无锁、有锁(重量级锁),而在JDK6之后对synchronized进行了优化,新增了两种状态,总共就是四个状态:无锁状态、偏向锁、轻量级锁、重量级锁,其中无锁就是一种状态了。锁的类型和状态在对象头Mark Word中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。
- Mark Word中的数据:
Monitor对象
每个对象都存在一个monitor与之相连,monitor存在于每个Java对象头中(存储的指针的指向)。对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,对象便处于锁定状态。所以说monitor是实现锁机制的基础,线程获取锁本质是线程获取Java对象对应的monitor对象。
- monitor对象中存在一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。
监视器monitor有两种同步方式:互斥与协作。
-
互斥的同步方式:在多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。
-
协作的同步方式:一个线程向缓冲区写数据,另一个线程从缓冲区读数据,如果读线程发现缓冲区为空就会等待,当写线程向缓冲区写入数据,就会唤醒读线程,这里读线程和写线程就是一个合作关系。
四.synchronized的优化
在上面的说明中,我们理解到synchronized在同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块或者同步方法之中,即表现为互斥性(排它性)。这种方法的效率是低下的。在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁monitor是依赖于底层的操作系统的 互斥锁 来实现的。
在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化等优化方法,又新增了两个锁的状态:偏向锁、轻量级锁。现在synchronized一共有四种锁的状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。
- 注:锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。给synchronized性能带来了很大的提升。
在说synchronized的优化之前,我们要知道什么是CAS。
CAS(乐观锁策略)
什么是CAS?
CAS(compare and swap)是一种乐观锁策略,线程获取锁是悲观锁策略,就是每一次执行临界区代码都会产生冲突,所以当前线程获取锁之后会阻塞其他线程。而CAS操作则假设所有线程访问共享资源时不会出现冲突,这样的假设自然而然不会阻塞其他的线程操作,因此线程就不会出现阻塞停顿的状态。在乐观锁条件策略下,如果线程要修改锁资源,CAS就会通过比较交换来鉴别线程是否会出现冲突,如果出现冲突就会重试当前操作直到没有冲突为止。
CAS的比较交换(compare and swap):
CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。
也就是说当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程
CAS的问题:
- ABA问题:因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。
- 解决方法:添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。
- 自旋时间过长:使用CAS时是非阻塞同步,也就是说不会将线程挂起,该线程会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。
- 只能保证一个共享变量的原子操作:当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。
- 解决方法:利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。
锁优化——锁膨胀
上面讲到锁优化之后有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。也就是说对象对应的锁是会根据当前线程申请,抢占锁的情况自行改变锁的类型。
偏向锁
研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
核心思想:如果该锁第一次被一个线程持有,那么锁就进入偏向模式,此时Mark Word的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位是否为偏向锁以及当前线程ID是否等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作,减少不必要的CAS操作。
原理:
① 线程申请锁时的判断:线程申请锁的时候首先都会检测Mark Word是否为可偏向状态,因为当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。
如果当前对象处于可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行同步代码块;否则尝试获取偏向锁。一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束,由于锁竞争应该释放此偏向锁。
② 尝试获取偏向锁:若当前对象的Mark Word中指向的持有锁的线程ID不是该线程ID,则该线程就尝试用CAS操作将自己的ThreadID放置到Mark Word中相应的位置,如果CAS操作成功,说明该线程成功获取偏向锁,进入到步骤3),否则由于锁竞争释放此偏向锁。
③ 获取偏向锁成功:此时ThreadID已经不为0了,而是持有锁的线程ID。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作直接执行同步代码块。
④ 锁竞争释放此偏向锁:当线程执行CAS失败,表示另一个线程当前正在竞争该对象上的锁。当到达全局安全点时(cpu没有正在执行的字节,这个时间点是上没有正在执行的代码,注意当前持有偏向锁的线程不执行并不一定就是它的操作已经执行完成,要释放锁了)之前持有偏向锁的线程将被暂停,撤销偏向。
然后判断锁对象是否还处于被锁定状态,如果没有被锁定,说明当前资源没有被线程使用,则恢复到无锁状态(01),以允许其余线程竞争。如果处于被锁定状态,说明当前资源正在被线程使用,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址(Lock Record)的指针放入对象头Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式;后续的同步操作就按照轻量级锁那样去执行。同时被撤销偏向锁的线程继续往下执行。
图解:
轻量级锁
轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。
核心思想:轻量级锁就是允许多个线程获得锁,但是只允许他们顺序拿锁,不允许出现竞争,也就是拿锁失败的情况。轻量级锁的加锁和解锁都是通过CAS操作实现。
原理:
- 加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁
- 解锁/升级为重量级锁:轻量级解锁时,会使用原子的CAS操作将原先复制的Mark Word替换回去到对象头(达成解锁),如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
图解:
重量级锁:
- 重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。
重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。
原理:自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
几种锁的比较和适用场景
- 各种锁的比较
- 各种锁各自的优缺点和适用场景