Java 多线程随笔

Java 线程

线程状态

主要有五种,定义在 Thread 类里:

public enum State {
    // 新 new 的还没有 start() 的线程,等同于操作系统中新建状态
    NEW,

    // 正在 JVM 中执行但是可能正在等待来自操作系统的其他资源,比如 CPU,等同于操作系统的就绪状态和运行状态
    RUNNABLE,

    // 正在等待一个监视锁 来首次进入或(在调用 Object.wait 后)重新进入同步块,在等待资源,故等同于操作系统的阻塞状态
    BLOCKED,

    /**
     * 没有在等待资源,故等同于操作系统的挂起状态
     *
     * 在下列情况发生时,线程会进入此状态:
     * 1. 无超时的 Object#wait() :挂起自己,等待某个线程调用 notify 或 notifyAll 唤醒自己
     * 2. 无超时的 thread#join() :挂起自己,等待某个线程完成后唤醒自己
     * 3. LockSupport#park()    :挂起自己,等待中断或者某个线程调用 unpark() 唤醒自己
     */
    WAITING,

    /**
     * 带有时间限制的 WAITING 状态,超过这个时间就自动结束这个状态,当然在这个时间内同样可以被唤醒。
     * 以下方法的调用会使一个线程进入此状态: 
     *
     *   Thread#sleep
     *   有超时的 Object#wait(long)
     *   有超时的 Thread#join(long) 
     *   LockSupport#parkNanos 
     *   LockSupport#parkUntil
     */
    TIMED_WAITING,

    // 终止状态,线程已完成执行或者抛出异常或者被 stop() 了
    TERMINATED;
}

创建线程

有三种方式:

    // 创建线程方式一:继承 Thread 类并重写 run() 方法并启动线程
    new Thread() {

        @Override
        public void run() {
            System.out.println("这是继承了 Thread 类,并重写了它的 run 方法,此方法将在线程启动后伺机执行");
        }
    }.start();

    // 创建线程方式二:实现 Runnable 接口,并将其交给 Thread 对象来执行,本质还是调用 Thread 对象调用自己的 run 方法
    new Thread(
            () -> System.out.println(
                    "这是 Runnable 类的一个匿名实现类,并将其作为 Thread 的构造参数,此方法将在线程启动后伺机执行")
    ).start();

    // 创建线程方式三:实现 Callable<T> 接口
    class Book{
        @Override
        public String toString() {
            return "这是一本书对象实例: " + super.toString();
        }
    }

    // 第一步:先创建一个 FutureTask ,构造参数是 Callable<T> 接口的匿名实现类
    FutureTask<Book> futureTask = new FutureTask<>(() -> {
        System.out.println("这是 Callable<T> 方法的实现类");

        return new Book();
    });
    // 第二步:创建一个线程并运行来执行这个 FutureTask
    new Thread(futureTask).start();
    
    // 第三步:获取线程执行结果,此方法将阻塞直到该线程执行完毕
    Book result= futureTask.get();

线程优先级

Java 线程优先级主要有三级:

  • MIN_PRIORITY (最小优先级):MIN_PRIORITY = 1
  • NORM_PRIORITY(普通优先级):NORM_PRIORITY = 5,默认是普通优先级
  • MAX_PRIORITY (最大优先级):MAX_PRIORITY = 10

JVM 线程调度

既然是多线程,必然就涉及到线程调度的问题,何为线程调度,简单点说,就是在 CPU 可用时由调度程序决定哪一个线程可以获取到 CPU 资源,进而可以执行子的任务。线程调度程序主要分为抢占式调度程序和时间片调度程序。

  • 所谓抢占式,就是每次调度时都将 CPU 资源给到就绪队列中优先级最高的线程,同时如果有新的并且具有更高优先级的线程进来抢夺 CPU 资源,那么调度程序将立即重新进行调度,以确保每个时刻都是优先级最高的线程在运行,也就是说一旦优先级最高的线程获得了 CPU 资源,那么它将一直占用 CPU 资源,直到运行结束或者有新的更高优先级的线程进来。

  • 而基于时间片的调度程序,在每次调度时都会根据某种条件来从就绪队列中的所有线程中选择一个合适的线程来执行,这个条件筛选可以有很多种,比如随机获取一个、选择队列头,当其基于优先级来选择时,其和抢占式调度就极为相似,但是在一个时间片内,不会被抢占,当一个时间片过后,正占有 CPU 资源的线程将重新进入就绪队列,然后进行新一轮的调度来确定下一个时间片由哪个线程占有 CPU 资源。

线程初始化

线程初始化主要完成的工作有以下几点:

  • 设置线程名,默认为 "Thread-" + ThreadId;
  • 设置这个线程所属的线程组(Thread Group),线程组获取顺序为 指定->安全管理器->父线程,同一个线程组的线程可以相互获取信息;
  • 继承父线程的一些属性,包括优先级、守护线程标识、类加载器;
  • 根据指定的参数 inheritThreadLocals 决定是否继承父类的本地线程变量(ThreadLocal)值;
  • 设置线程虚拟机栈的最大栈深度;
  • 设置线程 ID;

多线程同步机制

当有多个线程在同时运行时,如果它们之间没有任何交集,即不共享某个资源,那么可以不用对它们进行特殊处理,但是当多个线程共享一个资源或者需要协同合作时,那么就需要同步机制来确保它们之间的运行逻辑正确。举个常见的例子:

两个线程共享一个变量 count,初始值为 1,都执行一件事,count++,那么在两个线程运行结束后,我们期待 count 会由 1 变为 3. 但是事实往往不尽如人意。count++ 并不是原子操作,它会被分解为三个动作:

register1 = count;
register1 = register1 + 1;
count = register1;

正常情况下,我们希望它的执行顺序是这样的:

  // 线程 1 执行 count++
  register1 = count;            // register2 = 1;
  register1 = register1 + 1;    // register2 = 2;
  count = rgister1;             // count = 2;

  // 线程 2 执行 count++
  register2 = count;            // register2 = 2;
  register2 = register2 + 1;    // register2 = 3;
  count = rgister2;             // count = 3;

但在多线程环境下,由于任意一个线程都可能会在任意地方暂停执行(可能是由于分配给这个线程的时间片用完了,不得不暂停运行,抑或是其他不得不暂停运行的情况发生了),这样就有可能出现多种情况,例如,在单核 CPU 中其执行顺序可能是:

register1 = count;            // register1 = 1
register2 = count;            // register2 = 1;
register2 = register2 + 1;    // register2 = 2;
count = rgister2;             // count = 2;
register1 = register1 + 1;    // register1 = 2;
count = rgister1;             // count = 2;

执行完后,count = 2,这显然不是我们要的结果,那该如何防止这种问题出现呢?于是就有了同步锁机制。什么样的操作需要锁呢,首先,它必须是可以被多个线程访问的,其次,它可以被修改。

synchronized 关键字

这个是 JVM 内置的同步锁机制,称为内部锁,每个对象生来就有内部锁,而 synchronized 关键字就是依赖于某个对象锁的。

作用

synchronized 的作用主要有三:

  • 原子性:保证所有操作要么不做,要么全做,什么意思呢?那在我正在做这些操作的时候,时间片用完了,不得不暂停,后面又没叫我继续这些操作,这算原子性吗?如果能够确定在我执行这些操作的时间段内,包括时间片用完进入就绪队列的这些等待时间,其他任何人都不能来执行同样的操作,那么就是原子性的操作。

  • 可见性:和 volatile 关键字一样,它可以保证所有数据的可见性,什么意思呢?每个线程内部都有自己的内存空间(类似于有一些寄存器,又称为缓存区域),在对一个变量进行访问时,如果发现自己的缓存区中没有这个变量的存在,就会去主内存(堆)中获取到这个变量的值,然后缓存到自己的缓存区,下次就直接从缓存区取数据。当这个线程更改了这个变量后,它只是修改了缓存区中的变量值,而不是立即将其写入主内存,这就会导致一个问题:一个线程修改了一个变量的值,但是它还没有把它写入主内存,此时有一个线程来主内存取这个变量的值,由于新的值还在第一个线程的缓存区,所以主内存中仍然是旧值,那么,这个线程拿到的就不是最新的值,因此可能会导致各种问题。而所谓保证数据可见性,就是让线程在更改了某个变量的值后,不是保存在自己的缓存区中,而是直接或尽快写入内存,在读取的时候,不从自己的缓存区中拿数据,而总是直接从主内存中拿数据。volatile 关键字在每次对其所修饰的变量操作时都会直接从主内存区中进行操作而不再依赖缓冲区,而 synchronized 则是在每次成功获得锁进入同步块前从主内存中取值,在释放锁后将缓存区中的值写入主内存,在同步代码块期间,仍然可以使用缓冲区。

  • 有序性:Java 允许编译器和处理器对代码指令进行重排序,重排序并不会使程序出现错误,只是更改了某些代码指令的顺序性,比如:

      int i;
      int j;
    

    你先声明了两个变量, i 在 j 之前,但在最终指令中,可能 j 在 i 之前。在单线程中,指令重排序是没有任问题的,但是在多线程环境下,指令重排序就可能会出现问题。volatile 关键字保证单线程下指令的有序性,而 synchronized 关键字保证多线程之间的指令执行有序性,就像上面所举的 count++ 例子一样,可以保证线程 1 或线程 2 的指令先执行,执行完后另一个线程才开始执行它的指令,而不是两者交错执行。

使用方法

那要怎么用?以上面的例子为例,使用内置锁可以这样用:

// 共享资源 count
static int count = 1;

public static void testManyThread(){

    // 对象锁
    Object lock = new Object();

    // 线程 1
    new Thread(()-> {

        // synchronized 指定需要进入同步块的线程去竞争谁(哪个对象)的锁
        // 竞争对象锁,成功则进入就绪状态,失败则阻塞
        synchronized (lock) {
            count++;
            System.out.println(count);
        }
    }).start();


    // 线程 2
    new Thread(()-> {

        // 竞争对象锁,成功则进入就绪状态,失败则阻塞
        synchronized (lock) {
            count++;
            System.out.println(count);
        }
    }).start();
}

在上面的示例中,两个线程首先都会去竞争 lock 这个对象的对象锁,如果成功拿到锁,则进入就绪队列,等待调度程序来调度,否则就进入该对象的阻塞队列,待上一个持有锁的线程结束运行后,再次被唤醒,重复这种竞争过程,直到自己到达终止状态。这里主要体现的是多个线程对共享资源的互斥访问,而如果需要让多个线程按顺序执行,比如在上例中,如果我就想让线程 1 先执行呢?这时候,就涉及到线程同步问题了,即保证线程之间是协同合作的,我们可以这样做:

// 共享资源 count
static int count = 1;

// 标识当前是不是要线程 1 运行 
volatile static boolean isThread_1_Run = true;

public static void testManyThread(){

    // 对象锁
    Object lock = new Object();

    // 线程 1
    new Thread(()-> {

        // 竞争对象锁,成功则进入就绪状态,失败则阻塞
        synchronized (lock) {
            
            // 一直判定当前是否属于自己的运行时间直到是属于自己的运行时间,如果不是就阻塞自己
            while (!isThread_1_Run){
                try {
                    // 阻塞自己,就是将自己放入对象 lock 的阻塞队列中
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            count++;
            System.out.println(count);
            
            // 自己的运行时间结束了,标记一下下一次不是自己的运行时间
            isThread_1_Run = false;
            
            // 通知由于没有抢到这个对象锁而阻塞的线程 2
            lock.notify();
        }
    }).start();


    // 线程 2
    new Thread(()-> {

        // 竞争对象锁,成功则进入就绪状态,失败则阻塞
        synchronized (lock) {
            
            // 一直判定当前是否属于自己的运行时间直到是属于自己的运行时间,如果不是就阻塞自己
            while (isThread_1_Run){
                try {
                    // 阻塞自己,就是将自己放入对象 lock 的阻塞队列中
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            count++;
            System.out.println(count);

            // 自己的运行时间结束了,标记一下下一次不是自己的运行时间
            isThread_1_Run = true;
            
            // 通知由于没有抢到这个对象锁而阻塞的线程 1
            lock.notify();
        }
    }).start();
}

内置锁优化过程

大家有时间可以去看看这篇文章——《☆啃碎并发(七):深入分析Synchronized原理》,很全面了,在这里简单谈谈我的看法,所谓锁可升级,不可逆转,指的是 Synchronized 的底层是靠 JVM 实现的,而它的锁升级过程为 无锁->偏向锁->轻量级锁->重量级锁,在未升级到重量级锁之前,最重要的操作就是 CAS(Compare-And-Swap, 比较和交换),是什么来的呢?CAS 指定所期望的原值和所期望的新值,然后将所期望的原值和现在实际的值进行比较,如果一致,就将所期望的新值和现在实际的值进行交换,如果不一致,那就不交换,举个简单的例子:

我刚买了一只鸡腿,我将它放在碗里,正准备吃,突然想上厕所了,于是我离开了一会,等我回来一看,这鸡腿被咬了一口,但是我上厕所之前,这只鸡腿明明就是完整的(这是我所期望的原值),现在这个鸡腿被咬了一口,不完整了(这是现在实际的值),这个就是比较,那现在比较结果不一致,我按道理应该进行交换,但是我没有这个需求,我要去找找谁咬了我的鸡腿!如果一致,那就继续吃鸡腿了。

言归正传,我们来简单了解一下锁的原理。我们说,每个对象都天然可有锁,它的信息保存在这个锁对象的对象头中(简称其为锁头吧,保存在里面的信息就是锁头的信息),锁头信息包括锁头状态和每个状态的附加信息,锁头的状态及其附加信息主要有四种:

  • 无锁:就是还没有上锁,表示没有任何线程持有该锁,其锁标志位为 01,偏向锁标志为 0, 其余信息为哈希码等;
  • 偏向锁:就是已经上锁了,但是截至目前只有一个线程持有该锁,其锁标志位为 01,偏向锁标志位为 1,其余信息为持有该锁的线程 ID;
  • 轻量级锁:也是已经上锁了,其锁标志位为 00,其余信息为持有该锁的线程中的虚拟机栈信息记录;
  • 重量级锁:也是已经上锁头,其锁标志位为 10,其余信息为指向操作系统互斥量指针的记录信息;

每个线程不管是何种锁,只要想要获取该锁,必然要先访问锁头,看看这个锁头是否已经上锁,这叫竞争锁,是使用 CAS 操作完成的,线程总是期望锁头是未上锁的。锁头上锁后之所以还分为三种状态,是因为在每种状态下,线程发现锁头已上锁后的处理方式是不同的。

  • 偏向锁:首先看看持有该锁的线程是不是自己,若是则获得锁,否则看看持有该锁的线程是否仍存活,若已终止则直接修改 CAS(期望值是原来的线程 ID,新值是自己的 ID) 更新锁头里的线程 ID 为自己的 ID,成功则获得锁,否则就是有线程半路杀了出来,把锁抢走了,这时,就要找一个全局安全点(据说这个点是没有字节码在 JVM 中运行时),暂停那个半路杀出来的线程,然后升级为轻量级锁(修改锁标志位为 00);若持有该锁的线程还存活,做法就是相当于被半路杀出来的线程抢了锁头一样。理论上持有锁的线程都要释放锁,也就是将自己从锁头中复制过来的信息给它还原回去,但是一旦发现是偏向锁,就不用还原了,也就是说锁依然为此线程持有;如果发现不是偏向锁了,那就执行轻量级锁的释放方式,同样使用 CAS 操作将自己保存的锁头信息写回去,注意此时期望值是自己的线程 ID,但是由于锁头已经升级了,里面的实际信息就不是自己的线程 ID 了,这样更新就失败了,失败了也就知道锁升级了。

  • 轻量级锁:尝试获取锁的那个线程线程的那个线程进行自旋操作,在尝试了规定次数后仍然无法获取锁,就升级为重量级锁。

  • 重量级锁:想要获取锁的线程获取失败后一律阻塞。

可重入锁

如何锁

非公平锁

无参构造函数默认使用非公平锁,它的锁方法如下所示:

    final void lock() {
        // 如果原来没有线程持有此锁,尝试使用CAS获取它的锁,如果成功就可以直接获得锁
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            // 否则便是有线程持有该锁,就开始获取锁的漫漫长路
            acquire(1);
    }

    public final void acquire(int arg) {

        // 首先尝试获取锁,失败则从队列中获取锁直到成功
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))

            // 如果有中断发生,那就中断自己
            selfInterrupt();
    }

如何尝试获取锁?tryAcquire(arg) 会调用此方法:

    // 不公平地获取锁
    final boolean nonfairTryAcquire(int acquires) {

        // 获取当前线程
        final Thread current = Thread.currentThread();

        // 获取当前锁状态
        int c = getState();

        // 如果没有线程持有锁
        if (c == 0) {

            // 那么尝试使用CAS修改锁状态,如果成功则当前线程获得锁成功
            if (compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {

            // 否则如果当前线程就是当前持有锁的线程,直接修改锁状态
            int nextc = c + acquires;
            if (nextc < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }

        // 其余情况都算是尝试获取锁失败
        return false;
    }

简单来说就是,如果没有线程持有这个锁,那我就拿到锁了;如果当前拿着锁的线程就是我自己,那我又拿到锁了;其他情况都拿不到锁。锁可被多个线程持有,故名可重入。

// 从等待队列中获取锁,挂起式等待
final boolean acquireQueued(final Node node, int arg) {

    // 定义获取锁的结果,默认成功
    boolean failed = true;
    try {
        // 标识是否被中断,默认没有
        boolean interrupted = false;

        // 重复以下步骤直到成功获取到锁
        for (;;) {
            // 获取当前节点的前一个节点
            final Node p = node.predecessor();

            // 如果前一个节点是头节点,那么就尝试获取锁
            if (p == head && tryAcquire(arg)) {

                // 如果成功获取到锁,当前节点就是头节点了
                setHead(node);

                // 让 p 这个对象不可达
                p.next = null; // help GC

                // 获取成功了
                failed = false;

                // 返回中断标识
                return interrupted;
            }

            // 如果当前节点应该睡觉,那就将当前线程挂起(挂起后这个循环将不再获得 CPU 资源,直到被唤起才会获得 CPU 资源),并且检查一下是否有中断发生了
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())

                // 如果在当前线程被挂起之后,有当前线程的中断发生了
                interrupted = true;
        }
    } finally {
        // 如果最终获取锁失败了,那就不拿了!
        if (failed)
            cancelAcquire(node);
    }
}

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    // 前一个节点处于什么状态
    int ws = pred.waitStatus;

    // 如果它的前一个节点处于将会释放锁的状态,那么它就可以放心睡觉了,因为它的前一个节点会叫醒它
    if (ws == Node.SIGNAL)
        return true;

    // 如果它的前一个节点处于放弃锁的状态
    if (ws > 0) {

        // 那么在队列中一直往前找,直到找到不放弃锁的节点
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);

        // 将那些放弃锁的节点都扔了
        pred.next = node;
    } else {

        // 如果不是上面那些情况,也就是那些既没有放弃锁又不会叫醒我的节点,那就让它们会叫醒我
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }

    // 然后说,我还不可以睡觉!
    return false;
}

private final boolean parkAndCheckInterrupt() {

    // 令当前线程挂起
    LockSupport.park(this);

    // 返回是否有线程被中断了
    return Thread.interrupted();
}
posted @ 2022-04-03 00:16  lizhpn  阅读(35)  评论(0编辑  收藏  举报