并发编程的主要目的在于让程序运行的更快,但是并不一定线程越多,程序就运行的越快,有时也会受到硬件、软件资源的限制,这里主要涉及到上下文切换和死锁的问题。
这里就不再阐述并发和并行的区别了,并发执行程序的过程,就是CPU通过给每个线程一定的时间片去运行,而每个时间片又是毫秒级别,所以线程之间的切换是非常迅速的,人是感知不到这种切换的,以至于让人感觉到貌似是同时执行的。
操作系统中,我们知道,进程之间的切换是有较大开销的,所以逐渐引入了更小单位——线程,而线程之间的切换同样也是有一定开销的,CPU通过时间片分配算法来循环执行任务(实现CPU资源的调度,分配给不同线程),每次切换前,必须要保存上一任务的状态,以便于下一次切换回该任务时,可以再加载这个任务状态。——这就是上下文切换:从保存到再加载任务的过程。
上下文切换是会影响到执行效率的。如下这个例子,两个简单的执行方法,用并行和串行分别执行,当循环次数不超过百万次的时候,并行其实是比串行要慢的,这就源于——上下文的开销
package com.mybatisplus.example.jena;
/**
* @author :erickun
* @date :Created in 2021/11/14 8:57 下午
* @description:
* @modified By:
* @version: $
*/
public class ConcurrencyTest {
private static final long count = 10000l;
public static void main(String[] args) throws InterruptedException {
concurrency();
serial();
}
private static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
}
});
thread.start();
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
thread.join();
System.out.println("concurrency :" + time + "ms,b=" + b);
}
private static void serial() {
long start = System.currentTimeMillis();
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
System.out.println("serial:" + time + "ms,b=" + b + ",a=" + a);
}
}
为了减少上下文切换这个开销,核心思路在于尽量不要让线程之间去竞争锁:
无锁并发编程:让不同的线程去处理不同段的数据,这就不用去竞争锁而引发上下文切换。
CAS算法:Java的Atomic包下的原子操作CompareAndSet(自旋锁)
使用最少的线程:避免创建不需要的线程,这样会造成大量线程等待。
协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
死锁,简单来说,就是两个线程在互相等待对方释放锁,从而形成的一种僵持局面,两方都达不到释放锁的条件,两个线程卡死无法执行下去。
一般可能会遇到,t1线程拿到锁后,由于异常情况、死循环没有释放锁,又或者t1拿到了数据库锁,释放锁的时候又出现了异常,导致没释放掉。
出现了死锁,如何排查是有必要了解的。
可以使用jstack命令dump线程信息
如下样例:
/**
* @author :erickun
* @date :Created in 2021/11/14 9:53 下午
* @description:
* @modified By:
* @version: $
*/
public class JstackCase {
public static Executor executor = Executors.newFixedThreadPool(5);
public static Object lock = new Object();
public static void main(String[] args) {
Task task1 = new Task();
Task task2 = new Task();
executor.execute(task1);
executor.execute(task2);
}
static class Task implements Runnable{
@Override
public void run() {
synchronized (lock){
calculate();
}
}
public void calculate(){
int i = 0;
while (true){
i++;
}
}
}
}
执行命令:
jstack 49160 >log.txt
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。 例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s每秒,系统启动10个线程下载资 源,下载速度不会变成10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源限 制有带宽的上传/下载速度、硬盘读写速度和CPU的处理速度。软件资源限制有数据库的连接 数和socket连接数等。
导致的问题:
在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行, 但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不 会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,之前看到一段程 序使用多线程在办公网并发地下载和处理数据时,导致CPU利用率达到100%,几个小时都不 能运行完成任务,后来修改成单线程,一个小时就执行完成了。
如何解决问题:
对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多机上运行。比如使用ODPS、Hadoop或者自己搭建服务器集群,不同的机器处理不同 的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这 笔数据。
对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket 连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。
Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和 CPU的指令。
volatile和synchronized在并发编程中最为基础和重要!volatile是轻量级的synchronized,在多处理器开发中保证了“可见性”。
“可见性”——当一个线程 修改一个共享变量时,另外一个线程能读到这个修改的值。
volatile比Synchronized执行成本低的原因:不会引起线程的上下文切换和调度。
volatile如何实现来保证多个线程之间同一个变量的可见性呢?这要从CPU角度去剖析:
java:
instance = new Singleton(); // instance是volatile变量
汇编代码:
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,Lock前缀的指令在多核处理器下会引发了两件事情。
将当前处理器缓存行的数据写回到系统内存。
这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
对缓存行的理解:
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到**内部缓存(L1,L2)**或其他)后再进行操作,但操作完不知道何时会写到内存。
如果对声明了volatile的 变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当 处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
简而言之:
volatile操作,每个处理器会将更新后的变量从缓存区(本地内存)刷回系统内存(总线内存),其他的处理器利用——缓存一致性协议,不断的去看总线上值和自己缓存的值是否相等来判断版本过期没,再更新,处理器如果对volatile变量进行修改时,不仅会刷回总线内存,其他处理器也要去总线内存上更新各自的缓存值。
关键字:jvm -> 汇编Lock指令 -> 缓存刷回系统内存 -> 缓存一致性 : 其他处理器更新缓存
相比于volatile的轻量级,Synchronized是重量级锁,由于太重量级,jdk1.6之后对齐进行了一些列优化,为了减少获得锁和释放锁的性能消耗而引入了偏向锁和轻量级锁,后面会介绍锁升级过程。
从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。
代码块同步是使用monitorenter 和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。
细节:
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结 束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有 一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
下面可以通过反编译来看具体过程:
解释:
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
参考链接:
package com.paddx.test.concurrent;
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}
}
对于普通同步方法,锁是当前实例对象。
对于静态同步方法,锁是当前类的Class对象。
对于同步方法块,锁是Synchonized括号里配置的对象。
参考链接:
深入理解这三句话:
“锁”本身是个对象,synchronized这个关键字不是“锁”。硬要说的话,加synchronized仅仅是相当于“加锁”这个操作。
所以,所谓的加锁,严格意义上不是锁住代码块!如果这样想的话,后面很多问题就没法解释了。
补充几个概念:
案例1:
t1线程执行m1方法时要去读this对象锁,但是t2线程并不需要读锁,两者各管各的,没有交集(不共用一把锁)
案例2:
同一个类中的synchronized method m1和method m2互斥吗?
synchronized是可重入锁,可以粗浅地理解为同一个线程在已经持有该锁的情况下,可以再次获取锁,并且会在某个状态量上做+1操作
案例3:
子类同步方法synchronized method m可以调用父类的synchronized method m吗(super.m())?
在JVM学习中了解到子类new的类初始化过程:
步骤是:父类静态变量、父类成员变量、子类静态变量、子类成员变量
子类对象初始化前,会调用父类构造方法,在结构上相当于包裹了一个父类对象,用的都是this锁对象
案例4:
静态同步方法和非静态同步方法互斥吗?
参考链接
一个Java对象由三部分组成:
对象头
实例数据
对齐填充字节
java的对象头由以下三部分组成:
1,Mark Word
2,指向类的指针
3,数组长度(只有数组对象才有)
Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。
Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:
其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。
JDK1.6以后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。
JVM一般是这样使用锁和Mark Word的:
1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。
3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。
4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。
5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。
7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。
该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
Java对象的类数据保存在方法区。
只有数组对象保存了这部分数据。
该数据在32位和64位JVM中长度都是32bit。
对象的实例数据就是在java代码中能看到的属性和他们的值。
因为JVM要求java的对象占的内存大小应该是8bit的倍数,所以后面有几个字节用于把对象的大小补齐至8bit的倍数,没有特别的功能。
原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意 为“不可被中断的一个或一系列操作”。在多处理器上实现原子操作就变得有点复杂。下面介绍处理如何实现原子操作。
处理器是通过提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性的
就是使用处理器提供的一个 LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下 使用缓存锁定代替总线锁定来进行优化。
解释:
频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁
自旋锁不是一个锁状态,只是代表不断的重试。区别于synchronized同步锁的一种乐观锁,底层没有用到Synchronize同步操作,性能高于Synchronized。
AtomicInteger类compareAndSet通过原子操作实现了CAS操作,最底层基于汇编语言实现。
简单说一下原子操作的概念,“原子”代表最小的单位,所以原子操作可以看做最小的执行单位,该操作在执行完毕前不会被任何其他任务或事件打断。(执行一气呵成)
CAS是Compare And Set的一个简称,如下理解:
1,已知当前内存里面的值current和预期要修改成的值new传入
2,内存中AtomicInteger对象地址对应的真实值(因为有可能别修改)real与current对比,
相等表示real未被修改过,是“安全”的,将new赋给real结束然后返回;不相等说明real已经被修改,结束并重新执行1直到修改成功。
这里的“自旋”指的就是如果更新不了,就在循环中反复判断。
import java.util.concurrent.atomic.AtomicReference;
/**
* Created by Jack on 2017/1/7.
*/
public class AtomicReferenceTest {
public static void main(String[] args) {
// 创建两个Person对象,它们的id分别是101和102。
Person p1 = new Person(101);
Person p2 = new Person(102);
// 新建AtomicReference对象,初始化它的值为p1对象
AtomicReference ar = new AtomicReference(p1);
// 通过CAS设置ar。如果ar的值为p1的话,则将其设置为p2。
ar.compareAndSet(p1, p2);
Person p3 = (Person) ar.get();
System.out.println("p3 is " + p3);
System.out.println("p3.equals(p2)=" + p3.equals(p2));
}
}
class Person {
volatile long id;
public Person(long id) {
this.id = id;
}
public String toString() {
return "id:" + id;
}
}
分析:
新建AtomicReference对象ar时,将它初始化为p1。
紧接着,通过CAS函数对它进行设置。如果ar的值为p1的话,则将其设置为p2。
最后,获取ar对应的对象,并打印结果。p3.equals(2)的结果为true, 指向的是同一对象。
CAS的三大问题:
CAS需要在操作值的时候,检查值是否改变,而如果是值从A改到B又改回了A,也无法发现其改变,但实际是变化了,ABA问题的解决思路就是使用版本号,每次更新的时候把版本号+1.AtomicStampedReference的compareAndSet方法的作用就是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,全部相等才会更新。
在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递
首先,内存模型针对的是共享区域的变量,对于每个线程所拥有的局部变量、方法定义参数、异常处理器参数,是不会在线程之间共享的,这里回顾一下JVM,每个线程独占的有虚拟机栈、本地方法栈、程序计数器,而共享区域是堆内存和元空间。
线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的 一个抽象概念,并不真实存在
整个过程来看,其实实质上是线程A在向线程B发送消息,而且必须要经过主内存,JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)
JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。
虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行 顺序,不一定与内存实际发生的读/写操作顺序一致!
举例:
过程分析:
这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存 中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3, B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。
也就是说,处理器在读b值的时候,实际上线程B对b值的修改还没有更新到主内存上去,所以读到的是初始值,对于线程B来说也是一样。
原因分析:
总结一句话就是:写缓冲区仅仅只对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致!
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。
确保load1数据装在先于Load2及后续装在指令的装载
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一 个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关 系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
一个线程中的每个操作,happens-before于该线程中的任意后续操作。
对一个锁的解锁,happens-before于随后对这个锁的加锁。
对一个volatile域的写,happens-before于任意后续对这个volatile域的 读。
如果A happens-before B,且B happens-before C,那么A happens-before C。
注意:
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个 操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一 个操作按顺序排在第二个操作之前
一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。
编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
仅针对单个处理器中执行的指令序列和单个线程中执行的操作, 不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
举例:
double pi = 3.14; // A
double r = 1.0; // B
double area = pi*r*r; //C
A和B之间重排,只要在C之前,怎么做优化都可!
如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个 操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前
共同的目标:在不改变程序执行结果的前提下, 尽可能提高并行度。编译器和处理器遵从这一目标,从happens-before的定义我们可以看出, JMM同样遵从这一目标。
顺序一致性内存模型只是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
同步情况下,该顺序一致性模型如下:
非同步情况下:
JMM与顺序一致性模型对比:
顺序一致性模型(理想),就是一个线程中的操作严格按照代码的顺序来执行。
JMM中,不保证单线程内按照程序顺序来执行,临界区内的代码可以重排序。
JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程A在临 区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临 界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
为了实现volatile内存语义,JMM 会分别限制编译器重排序和处理器重排序。
规则如下图:
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来 禁止特定类型的处理器重排序。下面是基于保守策略的JMM内存屏障插入策略:
volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以 确保对整个临界区代码的执行具有原子性。
在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替锁,请一定谨慎,具体详情请参阅Brian Goetz的文章《Java理论与实践:正确使用Volatile变量》。
volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
严格 限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile 变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
这里以ReentrantLock 实现为例。在ReentrantLock中,调用lock()方法获取锁;调用unlock()方法释放锁。ReentrantLock 的实现依赖java同步器框架 AbstractQueuedSynchronizer (简称AQS),AQS使用一个volatile类型的int变量来维护同步状态。
公平锁加锁代码如下:
可以看到加锁前首先读取volatile变量,如果未加锁则使用cas设置值尝试加锁,加锁成功将执行线程更新为当前线程。如果已经加锁并且是当前线程加锁则修改state的值, 其他情况就是加锁失败。
释放锁代码如下:
释放锁由于肯定是单线程的,所以最后直接更新state的值(写volatile)即可,非公平锁和公平锁类似,使用对volatile变量的cas操作来试下加锁。这个操作包含了volatile 的读和写。根据volatile语义,不允许对volatile写之前和读之后的指令重排序,这样就实现了锁的语义。
综上:对于ReentrantLock源码的分析,锁释放获取语义的实现方式至少有如下两种:
volatile变量的读写 以及cas操作可以实现线程之间的通信。这是concurrent包的基石。仔细分析concurrent包的源码,会发现一个通用的实现模式
AQS,非阻塞数据结构和原子变量类(atomic包下的类),这些基础类都使用这种模式实现,而concurrent包下的高层类都是依赖这些基础类实现的 比如上文提到的 ReentrantLock。所以 volatile和cas 是并发编程的基础。
简而言之:就是让final变量的初始化在构造函数之内,避免处理器重排序到之外,导致引用错误。
由于这两个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是找到一个 好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。
简而言之:在保证结果正确性的前提下,编译器、处理器怎么做优化都可以,只要不改变程序的执行结果
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提 下,尽可能地提高程序执行的并行度。
下面是非线程安全的延迟初始化对象的示例代码。
public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance() {
if (instance == null) instance = new Instance();
return instance;
}
}
// 1:A线程执行
// 2:B线程执行
假设A线程执行代码1的同时,B线程执行代码2。此时,线程A可能会看到instance引用的对象还没有完成初始化
对于UnsafeLazyInitialization类,我们可以对getInstance()方法做同步处理来实现线程安全 的延迟初始化:
public class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if (instance == null) instance = new Instance();
return instance;
}
}
由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。反之,如果getInstance()方法不会被 多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。
在早期的JVM中,synchronized存在巨大的性能开销。因此, 人们想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。
此为错误优化:在线程执行到第4行,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。
分析问题根源:
但实际上2和3之间,可能会被重排序,如果发生重排序,那么另一个线程引用到了对象变量时,可能该对象还没有初始化完毕,从而发生错误
当声明对象的引用为volatile后,instance=new Singleton();中的3行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止。
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在
执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。