【Java】Java多线程学习笔记(二) 相识篇
线程同步机制
锁简介
在Java多线程学习笔记(一) 初遇篇,我们用两个售货员卖票引出了,多线程编程会遇到的三个问题: 原子性、可见性、有序性。一般来说两个人去卖一堆票的时候,不会出现这个问题,因为人在拿票的时候是不可打断的,一个人拿了票,另一个人自然马上能从票堆中看到结果。我们自然也想到,多线程编程也采取类似的机制,在并发的访问共享资源(票)的时候,不再允许同时拿票,在一个人拿完票没卖出去之前,另一个人禁止拿票。放车票的桌子上放一个许可证,谁先拿到这个许可证,谁就可以卖票,将票卖出去之后,马上将许可证放回去,两个人再进行争抢。这样的机制可能会导致的问题是,假如第一个的人比较快,他将一直卖票,另一个人将处于"饥饿"状态(一直获取不到共享资源)。
事实上也不会有哪个车站采取这种机制卖票,因为他们都有程序员们为他们做系统,哈哈哈哈。就算没有程序员们为他们做系统,也没有哪个车站用这种机制卖票,严重的影响效率,因为人毕竟不是线程,共享资源卖票的时候,也不会有线程安全问题,但是CPU很快,即使采用这种机制将原来的并发转为串行,也很快,即使性能相对来说降低了一些,但是这也是没有办法的事情,有舍就有得。
我们将描述更为专业化一点,线程安全问题的产生前提是并发的访问共享变量(这里的访问包括写0,仅仅是读并不会产生线程安全问题),如何让解决线程安全问题呢? 简单而又粗暴的方法就是每个线程在访问共享资源的时候,首先去尝试获取共享资源对应的许可证,线程执行完毕后释放许可证,在持有许可证的线程未释放资源访问许可证之前,其他线程无法访问(访问等于读写操作),这也就保证了原子性。这也就是我们下面要讲的Synchronized,Synchronized能够保证可见性、原子性、可见性。
锁的调度策略
与其说是锁的调度策略,不如说是线程的调度策略。那什么是线程的调度策略? 我们这里讨论的场景是在加锁的情景下,就是在一个线程执行完毕,释放锁之后,采取什么样的调度策略去唤醒陷入阻塞的线程。如果是随机的,那么我们就称这个调度策略是非公平的,因为该线程释放锁之后,还可能再次抢占锁,有可能导致部分线程大部分时间都是出于其生命周期的阻塞状态(BLOCKED)。
那么公平锁呢?
在JVM的作用下,线程调度器会更倾向于选择哪些等待时间最长的线程。这就是公平的含义。
可重入的概念
ReentrantLock是Lock的默认实现类,Reentrant中文意为可重入的,什么是可重入的? 简单的讲就是当一个线程持有锁之后,其他线程能否在度申请获取该锁?如果一个线程持有一个锁的时候还能继续成功获取该锁那么我们就称该锁是可重入的,这也是可重入锁的来源,顺便提一下,Java中所有的锁都是可重入的。
如果你还是不明白,我们再来理解一下,我们将锁理解为一个许可证,问题就转变为了,线程能否重复获得一个许可证,这是不是听起来很奇怪,我们上面的模型是讲锁是一个许可证存放在对象头中,那么再度获取,那对象头里是存了多份许可证吗?
事实上我们将许可证更为具体一点,就会发现许可证也并不是那么难以理解。
简单的说,对象头中还维护了一个计数器属性。计数器属性的初始值为0,表示相应的锁还没有被任何线程持有。每次线程获取一次许可证的时候,该锁的计数器值会被加一。线程释放该许可证的时候,计数器减一。
synchronized(内部锁、悲观锁)
synchronized,这是我在学习Java多线程的时候遇到的第一个同步机制,这个关键字简单而有省事。
- 可以加在方法上,则该方法即被称为同步方法
- 可以作用在代码块上,则该代码块即被称为同步代码块
- 可以加在静态方法上,则该静态方法就被称为同步静态方法
事实上虚拟机对这三种不同类型的加锁方式,处理方式方式都是不同的,但是原理都是类似的,线程在执行到同步代码块、同步静态方法、同步方法的时候会先申请许可证,获取许可证之后,才能执行方法或代码快中的代码。那么问题来了,你说的许可证,他存放在哪里呢? 你不要获取许可证吗? 那许可证存在哪里呢?
许可证存在对象里面,对的,许可证放在对象里面。Java中的对象有三部分组成:
- 对象头
- 实例数据
- 对齐填充字节
不要问我,为什么对象里面还有对象填充字节是干什么的? 这个问题并不简单,牵扯比较多,三言两语无法解释。
我们现在已经得到了我们想要的东西,即这个许可证存放在对象头的markword里面,更为细节的,不是本篇讨论的重点,就像synchronized本质上还是操作系统中的管程。
在刚开始我学synchronized的时候,以为synchronized修饰代码块的时候只能放this,因为共享资源只属于当前对象,后来又仔细想想,这个锁只是起一个许可证的作用,事实上放哪个对象都无所谓,只要是一个对象就行,因为不同的对象发放的许可证肯定是不同的。所以synchronized还可以这么写:
Lock接口: 显式锁
这是我在学Java多线程同步机制遇到的第二个锁,当时我还不知道它还有个别名叫排它锁。什么叫排他锁? 一个锁此只能被一个线程所持有,我们就称它为排他锁或互斥锁。我在刚学Java多线程的时候遇到了很多名词: 排他锁、互斥锁、乐观锁、悲观锁、可冲入锁、读写锁。老实说刚开始我都被这些名词整懵逼了,怎么这么多锁,后来才慢慢的搞懂了,如果你不是太懂,不用担心,我将一一的解释这些词代表的含义。
Lock是Java中的一个接口,位于java.util.concurrent(我们常说的JUC)包下,JDK1.5引入。 其作用与内部锁相同,但是拥有了一些内部锁不具备的特性,但并不是内存锁(synchronized)的替代品。比如能够让开发者选择公平与非公平的调度策略。老实说,我在刚学线程同步机制的时候,觉得synchronized有点低端了,因为太简单了,那时的我总是想学一点"高端"的技术,哈哈哈哈,我当时就觉得CAS就是这两个的替代品,事实上并不是,这只是Java给我们的选择。顺便提一下,JDK1.6、1.7对内部锁做了优化(本篇不介绍做了哪些优化,这不是本篇的主题),在特定情况下减少了锁的开销,也就是假设你用的是JDK1.7以上,内部锁和显示锁性能相差无几,甚至内部锁的性能要好于外部锁。
ReentrantLock是Lock的默认实现类,我们先来大致的看下Lock:
public interface Lock {void lock(); // 申请锁
void lockInterruptibly() throws InterruptedException; 如果执行该方法的线程未被中断且在锁未被其他线程获取,则获取锁。
boolean tryLock();// 尝试去获取锁,如果锁没被获取则获取锁
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; 如果在给定的时间内,锁未被其他线程所获取,那么则获取该锁。
void unlock(); // 释放锁
Condition newCondition(); // 这个我们在讲线程协作的时候会讲
}
interrupt意为中断,事实上在java中,这个词用停止更为合适,我们如果将线程理解为一个工作者的话,那我们停止这个线程,那么就相当于取消了该任务,一般在比较耗时的任务执行过程中,用户等的时间长了可能就会取消这个任务。
显式锁的典型使用场景
内部锁的申请锁,释放锁都由JVM来控制,我们基本上无法加入,那么假如一个内部锁的持有线程一直都不释放这个锁呢?
这通常都是由代码错误造成的,那么同步在该锁上的线程就会一直陷入等待(同步在该锁: 也就是是多个线程访问同一个锁保护的共享数据),一般我们称这种现象为锁泄漏,即一个线程一直持有锁,其他线程无法获取到锁。那对于显示锁来说我们大致就可以这么操作,来避免锁泄漏:
private final Lock lock = new ReentrantLock();lock.lock()
try{
}finally{
lock.unlock(); //总是在finally中释放锁,避免锁泄漏。
}
我们也可以认为这是显式锁相对于内部锁的优势,能够更有效的避免锁泄漏,相对来说更加灵活。显式锁相对于内部锁来说更容易跨方法,假如线程干的活有多个方法构成的话。
CAS(compare and swap) 乐观锁
显式锁和内部锁,在一个线程获取到之后,其他线程再次尝试获取锁,就会进入Blocked(阻塞)状态,这看起来是怕其他线程跟他争用,这也是显示锁和内部锁被称作悲观锁的原因,总是假定在访问锁保护的共享资源的时候,我的时间片可能用尽了,其他线程再次访问锁保护的资源,为了避免这种情况的出现,那么在一个线程获取到锁之后,其他的锁再次获取锁,就别再争了,直接阻塞吧。
显示锁和内部锁的确解决了线程安全会出现的三个问题: 可见性、原子性、有序性。但总是让我感觉有点死板,太过悲观,不过我并没有悲观太久,很快我就碰到了第二种类型的锁: 乐观锁。通常我们用CAS代称。
我们再来分析一下,《初遇篇》引出线程安全的代码:
public class TicketSell implements Runnable {// 总共一百张票
private int total = 2000;
@Override
public void run() {
while (total > 0) {
System.out.println(Thread.currentThread().getName() + "正在售卖:" + total--);
}
}
}
total--事实上可以分解为三步:
- 读取total
- 更新total
- 将更新后的值写到主存中
然后这三步是可以被打断的,也就是说在执行到任意一步,时间片都可能耗尽,其他线程可能进来,再度进行total--。
那么我们能否考虑将这三步做成不可打断的,来保证线程安全呢。这是一种可行的思路,其中第三步即为可见性,我们用Volatile来保证。那么问题到这里就结束了吗? 并不是,还是存在问题,问题就是假设A、B线程刚执行完判断while(total > 0),时间片就耗尽了怎么办,也就是说C线程将2000写为1999,然后A和B读取到了,A执行完判断while(total > 0),时间片耗尽,然后B线程执行,也是执行完判断,时间片耗尽。那么就会出现,A和B都将1999改为1998,也就是两个售票员共同卖了一张票这样的情况。
那么这里的问题就是,在假设A线程已经执行完毕的情况下,B线程是认为A线程并没有执行的,如果我们将线程拟人化的话。那么我们提出的解决方案就是比较,比较主存和线程中持有的值是否相同,如果相同那么我们可以认为这个值是没有更新过的,如果不同,那么就说明已经有别的线程已经更新过该值。这也就是比较并交换。
在Java语言中,long型和double型以外的任何类型的变量的写操作都是原子操作,不可打断的。有同学可能会问,为什么?要讲清楚这个问题并不容易,后面会专门开一篇博客来讲这个原因。
上面的比较并交换的思想的源头是处理器指令的称呼(例如x86处理器中的compxchg)的称呼,在Java中原子变量类是基于CAS实现能够保障线程对共享变量操作时(读-修改-写)的原子性和可见性的一组工具类。原子变量类在某种意义上可以算是Volatile的增强类,我们知道Volatile能够保证可见性,但是无法保证原子性,那么原子变量类就相当在Volatile的基础上增加了原子性。
原子变量类一共有12个,可以被分为四组,如下图所示:
AtomicLong概览:
我们拿AtomicLong类拿出来大致讲一下,其他类的方法和操作都是类似的:
这里可能有同学会有疑问,你上面不是讲AtomicLong内部的Long变量不是用Volatile修饰吗? Volatile不是能够保证可见性吗?
你这个get方法拿到就应该是最新值啊!
我们知道原子变量类可以认为是无锁的,那么就会出现这样的情况,一个线程在调用AtomicLong的get方法获取原子变量完成时,另一个线程还未调用getAndIncrement完成,调用完成时,另一个线程已经读的就是更新之前的值。这里我们就要再度讨论一下Volatile关键字,通常情况下我们用Volatile来禁用重排序和保证可见性,但是这个可见性,我们可以理解为是一种相对可见性,就是说A线程更新了共享变量,B线程在读取共享变量的时候,能读取到最新值,可是B线程在A线程更新共享之前就读取到了共享变量,Votatile并不会将B线程对共享变量的更新刷新到A线程的私有内存。
下面我们尝试用AtomicInteger来改写一下上面的买票实现:
public class TicketSell implements Runnable {// 总共一百张票
private final AtomicInteger atomicInteger = new AtomicInteger(100);
@Override
public void run() {
sell();
}
public void sell() {
while (atomicInteger.get() > 0) {
System.out.println(Thread.currentThread().getName() + "正在售卖:" + atomicInteger.decrementAndGet());
}
}
}
问题到这里结束了吗? 到目前为止我们的假设都是建立在线程内部持有的共享变量的副本和主存中的共享变量相等,即认为主存中的共享变量是未被任何修改过的,但是这个假设是存在漏洞的,我们考虑这样一种场景,假设主存中的共享变量是A,
A、B两个线程看到的都是A,然后C线程将共享变量修改为B,D线程又将共享变量置为A。那么A、B线程在取修改共享的时候就会在思考,主存中的变量和我持有的变量相等,那么共享变量是没被修改过吗? 这也就是CAS中会出现的ABA问题。
ABA问题
某些情况下,我们是无法容忍ABA问题的,我们必须告知此时的线程别的线程已经更新过了,解决的方案就是加一个版本号,也有资料称之为修订号、时间戳。也就是说原先我们只维护一个共享变量,现在还需要维护一个版本号。AtomicStampedReference(也被称为邮戳)就是对于上面思想的实现。
// initialRef 是引用,initialStamp 是版本号public AtomicStampedReference(V initialRef, int initialStamp) {
pair = Pair.of(initialRef, initialStamp);
}
读写锁
多线程协作的另一种场景就是读多写少,若干线程负责对共享变量进行更新,若干线程负责对共享变量进行读取,上面的CAS、synchronized、Lock就不怎么适合这种场景了,因为这些锁都是针对写线程的。针对上面的场景Java推出了读写锁,java.util.concurrent.locks.ReadWriteLock是对读写锁的抽象,其默认实现类是ReentrantReadWriteLock。
ReadWriteLock一览:
读写锁是一种改进型的排它锁,读写锁允许多个线程可以同时读取(只能是读取共享变量,也就是读线程),但是一次只允许一个线程对共享变量进行更新(包括读取后更新)。任何线程在读取共享变量时,其他线程无法更新这些变量,一个线程更新共享变量的时候,其他线程无法访问共享变量。
读写锁,顾名思义,也就是读锁和写锁。读线程在访问共享变量的时候必须持有相应读写锁的读锁,读锁是可以被线程所持有,即读锁是 共享的,一个线程持有读锁并不妨碍其他线程获得该读锁。写线程在访问共享变量时,必须获取写锁,写锁是排他的,独占的。写线程在获取到写锁之后,其他线程无法在获得读写锁的读锁或写锁。任何一个线程在持有一个读锁的时候,其他线程无法对共享变量进行更新,这就保证了,读线程在读取共享变量期间没有其他线程能够对这些变量进行更新,从而使得读线程能够读到共享变量的最新值。
浅谈死锁
死锁Java官方并没有提供,由程序员写出,我们应当竭力避免我们的代码中出现死锁。什么是死锁呢? 我们用一个场景来解释,就是假设你去面试,面试官让你解释死锁就给你offer,你让给offer就解释死锁,求职者和面试官互相持有资源,等待对方释放,一直僵持,简单的说这就是死锁。
上面我们将锁抽象为一个许可证,那么根据我们上面的模型,我们就需要两个线程,两个许可证。
public class DeadLockThread implements Runnable {private static Object licenceA = new Object();
private static Object licenceB = new Object();
private boolean flag;
public DeadLockThread(boolean flag) {
this.flag = flag;
}
@Override
public void run() {
if (flag) {
synchronized (licenceA) {
System.out.println("求职者,请解释死锁......");
flag = false;
try {
System.out.println("让面试官沉睡10s,防止面试官执行 的太快");
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (licenceB) {
}
}
} else {
synchronized (licenceB){
flag = true;
System.out.println("你给我offer,我就给你解释死锁.....");
try {
System.out.println("求职者沉睡10s,防止求职者执行的太快");
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (licenceA){
}
}
}
}
}
public class DeadLockDemo {public static void main(String[] args) {
DeadLockThread deadLockDemo = new DeadLockThread(false);
new Thread(deadLockDemo).start();
new Thread(deadLockDemo).start();
}
}
总结一下
锁是用来解决线程安全问题的,我们可以将锁理解为JVM发放的访问资源的许可证,JVM发放的许可证可以分为悲观型、乐观型的。悲观型的就是认为竞争总是存在,一个线程获取了许可证之后,其他线程在获取失败之后就会陷入阻塞状态,等待获取许可证的线程执行之后,再度向JVM申请。synchronized和Lock是其中的代表,但是悲观锁中又可以分公平锁和非公平锁,在选择是公平锁的情况下,JVM会优先唤醒那些等待时间最长的线程,避免线程饥饿现象(一个线程始终无法获取许可证)。有悲观就会有乐观,乐观锁假定竞争发生的概率比较小,所以总会尝试去获取许可证,假如主存中的共享资源和线程中的副本相等,那么此时线程就会认定,共享资源没有发生更新,此时就会更新变量,假设不相等,那么线程就会认为共享变量已经被修改过了,此时线程就会再度去获取主存中的贡献资源。但是线程中的变量和主存中的共享量相等,认为这个共享资源没更新过的这个假设不总是成立,也就是ABA问题,在某些情况下,ABA我们是难以忍受的,所以解决方案是给共享变量打一个版本号,在更新变量时,我们不仅要求变量相等,也要求版本号相等。
假设一个许可证只能被一个线程持有我们就称这样的许可证为排他的,独占的,也就是独占锁、排它锁。那么有独占锁就会有共享锁,在读多写少的场景,悲观锁和乐观锁就有点不那么趁手了,这也就是读写锁的使用场景,读锁可以共享,但是任何线程持有读锁期间,写锁无法被获取,任何一个线程在持有写锁期间,读锁无法被获取。
参考资料:
- 《Java多线程编程实战指南》 黄文海
- Java的对象头和对象组成详解
以上是 【Java】Java多线程学习笔记(二) 相识篇 的全部内容, 来源链接: utcz.com/a/86946.html