Java并发实在是一个非常深的问题,这里仅仅简单记录一下Java并发的知识点。水太深。假设不花大量的时间感觉全然hold不住,可是眼下的精力全然不够,兴趣也不在这
什么是线程安全性
某个类的行为和其规范全然一致
当多个线程訪问某个类时。不管运行时环境採用何种调度方式或者这些线程将怎样交替运行。而且在主调代码中不须要不论什么额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的
原子操作(Atomic Operation)
原子操作是指不会被线程调度机制打断的操作,这样的操作一旦開始。就会一直运行到结束,中间不会有不论什么的上下文切换,它是不可切割的。
举一个常见的样例:a++。这个操作就不是一个原子性的操作,那么在多个线程訪问调用的时候,a的终于结果就非常有可能不是我们的预期值。
由于实际上a++这个操作能够分为已下三步:获取a的值,更新a的值,写回a的值。
缓存一致性
在共享内存的多处理器体系架构中,每一个处理器都拥有自己的缓存,而且定期地与主内存进行协调,在不同的处理器架构中提供了不同级别的缓存一致性。
这个缓存一致性能够通过volatile关键字来加深理解。
Volatile关键字
Volatile是一种较弱的同步机制,用来确保将变量的更新操作通知到其它线程。当把变量声明为volatile类型后。编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作一起重排序。volatile变量不会被缓存在寄存器或者对其它处理器不可见的地方。因此在读取volatile类型的变量时总会返回最新写入的值。
可是有一点是须要注意的。被volatile修饰的变量的的操作也应该是原子性的。不然相同会出先问题。
比如:
Volatile int a = 0;
// 非原子性操作。使用volatile不能保证同步,改用Synchronized
a++;
而为什么Volatile能够实现这样的功能呢?
这个要从它的实现原理说起,在x86处理器下通过工具获取JIT编译器生成的汇编指令来看Volatile的写操作实际上做了什么吧。
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);
有Volatile变量修饰的共享变量进行写操作的时候会多第二行代码。lock指令修饰。
而lock指令会做什么事呢?
- 将当前处理器缓存行的数据写回到系统内存
- 该写回内存的操作会引起在其它CPU里缓存了该内存地址的数据无效
在上面介绍缓存一致性的时候提到了,在共享内存的多处理器体系架构中,每一个处理器都拥有自己的缓存。而且定期地与主内存进行协调,在不同的处理器架构中提供了不同级别的缓存一致性。
那么这个时候也就有了以下的事情:
处理器为了提高处理速度,不直接和内存进行通讯,而是先将系统内存的数据读到内部缓存中再进行操作,但操作完毕后不确定什么时候会写回到内存。假设对声明了Volatile变量进行些操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。而由于缓存一致性协议,每一个处理器会通过嗅探在总线上传播的数据在来检查自己缓存的值是不是国旗了。当处理器发现自己缓存行相应的内存地址被改动。就会将当前处理器的缓存行设置为无效状态。
当处理器要对这个数据进行改动操作的时候,就会强制从系统内存中又一次读取数据到处理器缓存里。
这也就是Volatile实现的原理。
重排序
刚才上面总结到了重排序的概念。那什么是重排序呢?
简单的理解就是。当程序在运行的时候,假设JVM觉得两行代码之间的结果互不影响。那么在运行的过程中可能就会产生一个乱序的结果。
比如:
a = 3;
b = 4;
正常情况下我们会觉得a = 3肯定是比b=4先运行的。由于它在b的上面,可是实际上并非这样,由于b的运行结果并不依赖上一行a的结果。因此JVM就能够对两行代码进行一个重排序,可能a先运行,也可能b先运行
为什么要採用重排序?
重排序一般是编译器或运行时环境为了优化程序性能而採取的。
它能够分为两类:编译器重排序和运行时重排序。
顺序一致性模型:理想的模型是。各种指令运行的顺序是唯一且有序的,这个顺序就是它们被编写在代码中的顺序,与处理器或其它因素无关
顺序一致性模型的缺点:效率过低
编译器重排序的典型就是通过调整指令顺序,在不改变程序语义的前提下。尽可能的减少寄存器的读取、存储次数、充分服用寄存器的存储值。
假设第一条指令计算一个值赋给变量A并存放在寄存器中。第二条指令与A无关但须要占用寄存器(假设它将占用A所在的那个寄存器),第三条指令使用A的值且与第二条指令无关。那么假设依照顺序一致性模型。A在第一条指令运行过后被放入寄存器。在第二条指令运行时A不再存在。第三条指令运行时A又一次被读入寄存器,而这个过程中,A的值没有发生变化。
通常编译器都会交换第二和第三条指令的位置,这样第一条指令结束时A存在于寄存器中,接下来能够直接从寄存器中读取A的值,减少了反复读取的开销。
并发时的乱序问题
上面总结的重排序能够引起乱序,相同的,在并发时对局部变量进行操作也有可能会产生乱序的问题。由于在每一个线程中都拥有一个独立的栈,也就是独立的线程空间,当它运行时,会从主内存中读取该变量的值并存放到自己的线程栈中,对变量操作完毕后就会把值写回主内存空间。
可是这里就有一个问题了,那就是变量的写回操作发生的时间并不能够确定。
就算是线程A比线程B先读取数据,仍然有可能线程B先把值写回主内存,终于相同会造成一个得到的结果并非我们想要的值。
Happens-before(先行发生)
Java内存模型(JMM)为程序中全部的操作定义了一个偏序关系。称之为Happens-before。假设想要保证运行操作B的线程看到操作A的结果(不管A和B是否在同一个线程中运行)。那么在A和B之间必须满足Happens-Before关系。
假设两个操作时间缺乏Happens-Before关系。那么JVM能够对它们随意地重排序。
当一个变量被多个线程读取而且至少被一个线程写入时。假设在读写操作之间没有依照Happens-Before来排序,那么就会产生数据竞争的问题。
Happens-Before规则包括:
- 程序顺序规则:假设程序中操作A在操作B之前,那么在线程中A操作将在B操作之前运行
- 监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前运行
- volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前运行
- 线程启动规则:在线程上对Thread.Start的调用必须在该县城中运行不论什么操作之前运行
- 线程结束规则:县城中的不论什么操作都必须在其它线程检測到该线程已经结束之前运行,或者在Thread.join中成功返回。或者在调用THread.isAlive时返回false
- 中断规则:当一个线程在还有一个线程上调用interrupt时。必须在被中断线程检測到interrupt调用之前运行
- 终结器规则:对象的构造函数必须在启动终结器之前运行
- 传递性:操作A在B之前。B在C之前,那么A就必须在C之前运行
比如线程A:y=1 -》 lock M -》 x=1 -》unlock M
线程B: lock M -》 i=x -》 unlock M -》 j=y
当两个线程使用同一个锁进行同步时,在它们之间的happens-Before关系就是:A的unlock M运行完毕之后才干运行B的lock M方法,假设这两个线程是在不同的锁上进行同步的,那么就不能判断它们之间的动作顺序,由于在这两个线程的操作之间并不存在Happens-Before关系
Lock / Synchronized / ReentrantLock(独占锁 / 悲观锁)
Synchronized
内置锁,当它用来修饰一个方法或者一个代码块的时候,能够保证在同一时刻最多仅仅有一个线程运行该段代码。还有一个线程必须等待当前线程运行完这个代码快以后才干运行该代码块(未运行之前。该线程被堵塞)。同一时候,它也是一个可重入锁(Lock均可重入)
可是这里有一个非常关键的地方:当一个线程訪问object的一个synchronized(this)同步代码块时。还有一个线程仍然能够訪问该object中的非synchronized(this)同步代码块。
之前在单例模式中总结到了双重检查锁定模式,可是由于双重检查锁定模式在一定情况下存在非常严重的Bug。就没有在该博客中写出。
这里就对双重检查锁定模式进行一个分析
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
由于在new Singleton()的过程中。实际上是能够分为非常多步的,可大致分为三件事情:
- 给Singleton的实例分配内存
- 调用Singleton的构造函数。初始化字段
- 将singleton对象指向分配的内存空间(singleton != null)
可是,Java编译器是同意处理器乱序运行的。全部有可能让第二步和第三步乱序运行,也就是说假设在第二步被乱序(安排到了最后一步运行),当他还没运行的时候切换到了线程B,这个时候就会由于singleton已经不为null而直接跳出if判断,这样的话在我们以后的代码运行过程中使用的就是一个未经构造函数初始化的一个对象。【如今好像有在JDK层面有改进因此能够正常使用。具体的不清楚,以后再改动】
Lock
Lock是一个接口,它里面主要包括了以下的几个方法:
P.S.源代码里的凝视太多,这里就不贴了
void lock();
void lockInterruptibly();
boolean tryLock();
boolean tryLock(long time, TimeUnit unit);
void unlock();
Condition newCondition();
Lock它与内置加锁机制不同,它提供的是一种无条件的、可轮训的、定时的以及可中断的锁获取操作,全部的加锁和解锁的方法都是显式的。
一句话总结:Synchronized算是Lock的简化版本号。功能比之较少可是在程序运行完毕后会自己主动释放锁,而Lock必须手动释放锁
ReentrantLock
ReentrantLock它实现了Lock接口。并提供了与synchronized相同的相互排斥性和内存可见性。
那为什么还要提供一种机制跟内置锁十分类似的新加锁机制呢?
由于内置锁在一定情况下存在局限性。比如无法中断一个正在等待获取锁的进程,或者无法在请求获取一个锁时无限地等待下去,。无法实现非堵塞结构的加锁规则。
而在ReentrantLock中。它能够实现轮询锁、定时锁、中断锁等多种加锁方式,这也让它的应用场景变的很多其它。
同一时候在性能上:假设有越多的资源被耗费在锁的管理和调度上,那么应用程序得到的资源就越少。锁的实现方式越好,就须要越少的系统调用和上下文切换,而且在共享内存总线上的内存同步通信量也越少。
在Java5中。ReentrantLock的性能比内置锁高了非常多,可是在Java6中内置锁採取了一种类似与ReentrantLock中使用的算法来管理内置锁,有效地提高了可伸缩性,因此在Java6中。它们的吞吐量就非常接近了。
在公平性上,ReentrantLock能够创建一个非公平锁(默认)也能够创建一个公平锁。
公平锁:线程依照发出请求的顺序来获得锁(先到先得。不准插队)
非公平锁:当一个线程请求非公平锁时,假设在发出请求的同一时候该锁的状态变为可用,那么就跳过队列中全部的等待队列立马获得锁(也就是同意插队。申请的时候锁为可用状态就直接获取)
而对于公平锁和非公平锁来说,它们的效率也是显而易见的:
公平性将由于在挂起线程和恢复线程时存在的开销而极大的减少效率。
而非公平性由于是在请求时锁已经为可用状态就直接获取,不须要进行什么额外的操作。因此效率更高。
实际上:确保被堵塞的线程能终于获得锁就已经够用了,而且实际开销也小非常多。
当在一个激烈竞争的情况下,恢复一个被挂起的线程与这个线程真正開始运行之间存在着严重的延迟,这样的话就影响了效率。而假设我们採用非公平锁(也就是ReentrantLock的默认方式)。线程A释放锁时,B被唤醒然后尝试获取锁,与此同一时候C也请求这个锁。那么C非常有可能会在B被全然唤醒之前获得、使用以及释放这个锁。也就有可能会造成B获得锁的时刻并没有推迟,C也更早的获得了锁
那什么时候应该使用公平锁呢?
当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,那么就应该使用公平锁。
在Synchronize和ReentrantLock中怎样选择
上面总结了这么多,好像ReentrantLock的长处比Synchronize好太多,那为什么不直接取消掉Synchronize呢?我们自己该怎么选择呢?
Synchronize非常重要的几个长处就是:
- 无需手动释放锁,程序自己主动完毕。
假设在使用Lock的过程中忘记在finally中释放锁,那么尽管代码表面上能正式运行,可是实际上已经出了大问题,还有可能伤到其它代码。所以一般仅仅是在做一些内置锁不能完毕的需求时才考虑使用ReentrantLock,比如中断锁、轮询锁等等
- 调试的问题:Synchronized在线程存储中能够给出在哪些调用帧中获得了哪些锁。并能够检測和识别发生死锁的进程。
而ReentrantLock它仅仅是一个对象。JVM不知道哪些线程持有这个。
非堵塞同步机制(乐观锁)
加锁机制始终会存在一个挂起唤醒的操作,假设有多个线程同一时候请求锁,那么JVM就须要借助操作系统的功能,而在挂起和恢复线程等过程中存在着非常大的开销,而且通常存在着较长时间的中断。假设在竞争激烈的时候,调度开销与工作开销的比值会非常高。
此外,假设一个线程正在等待锁时,它不能做不论什么其它事情,同一时候假设被堵塞线程的优先级较高,而持有锁的线程优先级较低,那么问题更严重。也就是发生了优先级反转。即:高优先级的线程必须等到低优先级的线程释放锁,从而导致它的优先级会减少至低优先级线程的级别。
而近期的非常多并发算法研究都側重于非堵塞同步的机制,比如:Lock-free算法
Lock-free算法(无锁)
这个算法中主要使用到了一个CAS机制(Compare and swap),它包括了3个值,须要读写的内存位置V,须要进行比較的值A, 要写入的新值B。
它的原理就是:
当且仅当V的值等于A时,CAS才会通过原子方式用新值B来更新V的值,否则不运行不论什么操作
而当多个线程常识使用CAS同一时候更新同一个变量时,仅仅有当中一个线程能更新变量的值。而其它的线程都将失败。
可是失败的线程并不会被挂起。而是被告知在这次竞争中失败,并能够尝试再次尝试。由于一个线程在竞争CAS时失败不会堵塞,因此它能够决定是否又一次尝试,或者运行一些恢复操作,再或者不运行不论什么操作。这样的灵活性就大大减少了与锁相关的活跃性风险。
结语
并发的水实在太深,不花精力实在难以hold住,这里简单记录一下Java并发的知识点
參考
- 《Java并发编程实战》
- 聊聊并发系列博客
- JAVA中JVM的重排序具体介绍 ——也就是Java中为什么要使用重排序