关于java:Java多线程学习笔记二-相识篇

3次阅读

共计 8590 个字符,预计需要花费 22 分钟才能阅读完成。

在 Java 多线程学习笔记(一) 初遇篇中咱们提到多线程合作会遇到的三个问题: 原子性、可见性、有序性。那么为了解决这三个问题,Java 引入了线程同步机制,然而线程同步机制使用不当又会导致死锁(临时不懂不要紧,本文会进行具体的介绍)。很多时候你为了解决一些问题,引入了一种新的工具或办法,但同时这些新的工具或办法又会引入新的问题。

在解决完线程合作会遇到的问题之后,线程被视作一个工作单位的状况下,经常咱们的操作是组团作战,然而咱们又心愿他们像一个团队一样,各司其职,这也就是 Java 引入线程协调机制的起因。咱们并不举荐间接创立线程,线程绝对于一般的对象创立更为耗费资源,个别咱们举荐应用线程池(不懂不要紧, 甚欢篇会讲)。

线程同步机制

锁简介

在 Java 多线程学习笔记 (一) 初遇篇, 咱们用两个售货员卖票引出了,多线程编程会遇到的三个问题: 原子性、可见性、有序性。一般来说两个人去卖一堆票的时候,不会呈现这个问题,因为人在拿票的时候是不可打断的,一个人拿了票,另一个人天然马上能从票堆中看到后果。咱们天然也想到,多线程编程也采取相似的机制,在并发的访问共享资源(票) 的时候,不再容许同时拿票,在一个人拿完票没卖出去之前,另一个人禁止拿票。放车票的桌子上放一个许可证,谁先拿到这个许可证,谁就能够卖票,将票卖出去之后,马上将许可证放回去,两个人再进行争抢。这样的机制可能会导致的问题是,如果第一个的人比拟快,他将始终卖票,另一个人将处于 ” 饥饿 ” 状态(始终获取不到共享资源)。

事实上也不会有哪个车站采取这种机制卖票,因为他们都有程序员们为他们做零碎,哈哈哈哈。就算没有程序员们为他们做零碎,也没有哪个车站用这种机制卖票,重大的影响效率,因为人毕竟不是线程,共享资源卖票的时候,也不会有线程平安问题,然而 CPU 很快,即便采纳这种机制将原来的并发转为串行,也很快,即便性能相对来说升高了一些,然而这也是没有方法的事件,有舍就有得。

咱们将形容更为专业化一点,线程平安问题的产生前提是并发的访问共享变量(这里的拜访包含写 0,仅仅是读并不会产生线程平安问题),如何让解决线程平安问题呢? 简略而又粗犷的办法就是每个线程在访问共享资源的时候,首先去尝试获取共享资源对应的许可证,线程执行结束后开释许可证,在持有许可证的线程未开释资源拜访许可证之前,其余线程无法访问(拜访等于读写操作),这也就保障了原子性。这也就是咱们上面要讲的 Synchronized,Synchronized 可能保障可见性、原子性、可见性。

锁的调度策略

与其说是锁的调度策略,不如说是线程的调度策略。那什么是线程的调度策略? 咱们这里探讨的场景是在加锁的情景下,就是在一个线程执行结束,开释锁之后,采取什么样的调度策略去唤醒陷入阻塞的线程。如果是随机的,那么咱们就称这个调度策略是非偏心的,因为该线程开释锁之后,还可能再次抢占锁,有可能导致局部线程大部分工夫都是出于其生命周期的阻塞状态 (BLOCKED)。
那么偏心锁呢?

locks favor granting access to the longest-waiting thread.
引自 ReentrantLock 类相干正文

在 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 的对象头和对象组成详解
正文完
 0