在学习知识前,咱们先来看一个现象:java
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个线程,每一个线程都累加了1000000次,若是结果正确的话天然而然总数就应该是10 * 1000000 = 10000000。可就运行屡次结果都不是这个数,并且每次运行结果都不同。这是为何了?有什么解决方案了?这就是咱们今天要聊的事情。面试
在上一篇博文中咱们已经了解了java内存模型的一些知识,而且已经知道出现线程安全的主要来源于JMM的设计,主要集中在主内存和线程的工做内存而致使的内存可见性问题,以及重排序致使的问题,进一步知道了happens-before规则。线程运行时拥有本身的栈空间,会在本身的栈空间运行,若是多线程间没有共享的数据也就是说多线程间并无协做完成一件事情,那么,多线程就不能发挥优点,不能带来巨大的价值。那么共享数据的线程安全问题怎样处理?很天然而然的想法就是每个线程依次去读写这个共享变量,这样就不会有任何数据安全的问题,由于每一个线程所操做的都是当前最新的版本数据。那么,在java关键字synchronized就具备使每一个线程依次排队操做共享变量的功能。很显然,这种同步机制效率很低,但synchronized是其余并发容器实现的基础,对它的理解也会大大提高对并发编程的感受,从功利的角度来讲,这也是面试高频的考点。好了,下面,就来具体说说这个关键字。数据库
在java代码中使用synchronized但是使用在代码块和方法中,根据Synchronized用的位置能够有这些使用场景:编程
如图,synchronized能够用在方法上也可使用在代码块中,其中方法是实例方法和静态方法分别锁的是该类的实例对象和该类的对象。而使用在代码块中也能够分为三种,具体的能够看上面的表格。这里的须要注意的是:若是锁的是类对象的话,尽管new多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系。安全
如今咱们已经知道了怎样synchronized了,看起来很简单,拥有了这个关键字就真的能够在并发编程中驾轻就熟了吗?爱学的你,就真的不想知道synchronized底层是怎样实现了吗?多线程
如今咱们来看看synchronized的具体底层实现。先写一个简单的demo:并发
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class) {
}
method();
}
private static void method() {
}
}
复制代码
上面的代码中有一个同步代码块,锁住的是类对象,而且还有一个同步静态方法,锁住的依然是该类的类对象。编译以后,切换到SynchronizedDemo.class的同级目录以后,而后用javap -v SynchronizedDemo.class查看字节码文件:app
如图,上面用黄色高亮的部分就是须要注意的部分了,这也是添Synchronized关键字以后独有的。执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令。经过分析以后能够看出,使用Synchronized进行同步,其关键就是必需要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,不然就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程可以获取到monitor。上面的demo中在执行完同步代码块以后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还须要获取该锁吗?答案是没必要的,从上图中就能够看出来,执行静态同步方法的时候就只有一条monitorexit指令,并无monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不须要再次获取同一把锁。Synchronized先天具备重入性。每一个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。ide
任意一个对象都拥有本身的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,若是没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED状态(关于线程的状态能够看这篇文章post
下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:
该图能够看出,任意线程对Object的访问,首先要得到Object的监视器,若是获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会从新获取该监视器。
在上一篇文章中讨论过happens-before规则,抱着学以至用的原则咱们如今来看一看Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。继续来看代码:
public class MonitorDemo {
private int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public synchronized void reader() { // 4
int i = a; // 5
} // 6
}
复制代码
该代码的happens-before关系如图所示:
在图中每个箭头链接的两个节点就表明之间的happens-before关系,黑色的是经过程序顺序规则推导出来,红色的为监视器锁规则推导而出:线程A释放锁happens-before线程B加锁,蓝色的则是经过程序顺序规则和监视器锁规则推测出来happens-befor关系,经过传递性规则进一步推导的happens-before关系。如今咱们来重点关注2 happens-before 5,经过这个关系咱们能够得出什么?
根据happens-before的定义中的一条:若是A happens-before B,则A的执行结果对B可见,而且A的执行顺序先于B。线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取到的a的值为1。
在上一篇文章提到过JMM核心为两个部分:happens-before规则以及内存抽象模型。咱们分析完Synchronized的happens-before关系后,仍是不太完整的,咱们接下来看看基于java内存抽象模型的Synchronized的内存语义。
废话很少说依旧先上图。
从上图能够看出,线程A会首先先从主内存中读取共享变量a=0的值而后将该变量拷贝到本身的本地内存,进行加一操做后,再将该值刷新到主内存,整个过程即为线程A 加锁-->执行临界区代码-->释放锁相对应的内存语义。
线程B获取锁的时候一样会从主内存中共享变量a的值,这个时候就是最新的值1,而后将该值拷贝到线程B的工做内存中去,释放锁的时候一样会重写到主内存中。
从总体上来看,线程A的执行结果(a=1)对线程B是可见的,实现原理为:释放锁的时候会将值刷新到主内存中,其余线程获取锁时会强制从主内存中获取最新的值。另外也验证了2 happens-before 5,2的执行结果对5是可见的。
从横向来看,这就像线程A经过主内存中的共享变量和线程B进行通讯,A 告诉 B 咱们俩的共享数据如今为1啦,这种线程间的通讯机制正好吻合java的内存模型正好是共享内存的并发模型结构。
经过上面的讨论如今咱们对Synchronized应该有所印象了,它最大的特征就是在同一时刻只有一个线程可以得到对象的监视器(monitor),从而进入到同步代码块或者同步方法之中,即表现为互斥性(排它性)。这种方式确定效率低下,每次只能经过一个线程,既然每次只能经过一个,这种形式不能改变的话,那么咱们能不能让每次经过的速度变快一点了。打个比方,去收银台付款,以前的方式是,你们都去排队,而后去纸币付款收银员找零,有的时候付款的时候在包里拿出钱包再去拿出钱,这个过程是比较耗时的,而后,支付宝解放了你们去钱包找钱的过程,如今只须要扫描下就能够完成付款了,也省去了收银员跟你找零的时间的了。一样是须要排队,但整个付款的时间大大缩短,是否是总体的效率变高速率变快了?这种优化方式一样能够引伸到锁优化上,缩短获取锁的时间,伟大的科学家们也是这样作的,使人钦佩,毕竟java是这么优秀的语言(微笑脸)。
在聊到锁的优化也就是锁的几种状态前,有两个知识点须要先关注:(1)CAS操做 (2)Java对象头,这是理解下面知识的前提条件。
使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,因此当前线程获取到锁的时候同时也会阻塞其余线程获取该锁。而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的实现须要硬件指令集的支撑,在JDK1.5后虚拟机才可使用处理器提供的CMPXCHG指令实现。
Synchronized VS CAS
元老级的Synchronized(未优化前)最主要的问题是:在存在线程竞争的状况下会出现线程阻塞和唤醒锁带来的性能问题,由于这是一种互斥同步(阻塞同步)。而CAS并非武断的间线程挂起,当CAS操做失败后会进行必定的尝试,而非进行耗时的挂起唤醒的操做,所以也叫作非阻塞同步。这是二者主要的区别。
在J.U.C包中利用CAS实现类有不少,能够说是支撑起整个concurrency包的实现,在Lock实现中会有CAS改变state变量,在atomic包中的实现类也几乎都是用CAS实现,关于这些具体的实现场景在以后会详细聊聊,如今有个印象就行了(微笑脸)。
1. ABA问题 由于CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。好比一个旧值A变为了成B,而后再变成A,恰好在作CAS时检查发现旧值并无变化依然为A,可是实际上的确发生了变化。解决方案能够沿袭数据库中经常使用的乐观锁方式,添加一个版本号能够解决。原来的变化路径A->B->A就变成了1A->2B->3C。java这么优秀的语言,固然在java 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。
2. 自旋时间过长
使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,若是这里自旋时间过长对性能是很大的消耗。若是JVM能支持处理器提供的pause指令,那么在效率上会有必定的提高。
3. 只能保证一个共享变量的原子操做
当对一个共享变量执行操做时CAS能保证其原子性,若是对多个共享变量进行操做,CAS就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。而后将这个对象作CAS操做就能够保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。
在同步的时候是获取对象的monitor,即获取到对象的锁。那么对象的锁怎么理解?无非就是相似对对象的一个标志,那么这个标志就是存放在Java对象的对象头。Java对象头里的Mark Word里默认的存放的对象的Hashcode,分代年龄和锁标记位。32为JVM Mark Word默认存储结构为(注:java对象头以及下面的锁状态变化摘自《java并发编程的艺术》一书,该书我认为写的足够好,就没在本身组织语言班门弄斧了):
如图在Mark Word会默认存放hasdcode,年龄值以及锁标志位等信息。
Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争状况逐渐升级。锁能够升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提升得到锁和释放锁的效率。对象的MarkWord变化为下图:
HotSpot的做者通过研究发现,大多数状况下,锁不只不存在多线程竞争,并且老是由同一线程屡次得到,为了让线程得到锁的代价更低而引入了偏向锁。
偏向锁的获取
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,之后该线程在进入和退出同步块时不须要进行CAS操做来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。若是测试成功,表示线程已经得到了锁。若是测试失败,则须要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):若是没有设置,则使用CAS竞争锁;若是设置了,则尝试使用CAS将对象头的偏向锁指向当前线程
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,因此当其余线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
如图,偏向锁的撤销,须要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,而后检查持有偏向锁的线程是否活着,若是线程不处于活动状态,则将对象头设置成无锁状态;若是线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么从新偏向于其余线程,要么恢复到无锁或者标记对象不适合做为偏向锁,最后唤醒暂停的线程。
下图线程1展现了偏向锁获取的过程,线程2展现了偏向锁撤销的过程。
如何关闭偏向锁
偏向锁在Java 6和Java 7里是默认启用的,可是它在应用程序启动几秒钟以后才激活,若有必要可使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。若是你肯定应用程序里全部的锁一般状况下处于竞争状态,能够经过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态
加锁
线程在执行同步块以前,JVM会先在当前线程的栈桢中建立用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。而后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。若是成功,当前线程得到锁,若是失败,表示其余线程竞争锁,当前线程便尝试使用自旋来获取锁。
解锁
轻量级解锁时,会使用原子的CAS操做将Displaced Mark Word替换回到对象头,若是成功,则表示没有竞争发生。若是失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,致使锁膨胀的流程图。
由于自旋会消耗CPU,为了不无用的自旋(好比得到锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其余线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁以后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
通过上面的理解,咱们如今应该知道了该怎样解决了。更正后的代码为:
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() {
synchronized (SynchronizedDemo.class) {
for (int i = 0; i < 1000000; i++)
count++;
}
}
}
复制代码
开启十个线程,每一个线程在原值上累加1000000次,最终正确的结果为10X1000000=10000000,这里可以计算出正确的结果是由于在作累加操做时使用了同步代码块,这样就能保证每一个线程所得到共享变量的值都是当前最新的值,若是不使用同步的话,就可能会出现A线程累加后,而B线程作累加操做有多是使用原来的就值,即“脏值”。这样,就致使最终的计算结果不是正确的。而使用Syncnized就可能保证内存可见性,保证每一个线程都是操做的最新值。这里只是一个示例性的demo,聪明的你,还有其余办法吗?
参考文献
《java并发编程的艺术》