Java—并发编程指南(上)

333 阅读28分钟

并发编程是提高程序运行效率与响应速度的重要手段,在多CPU条件下,并发编程可以使硬件得到更大程度的运用。由于在并发环境下CPU随时会对多线程的运行进行调度,因此线程中各指令执行的顺序是不确定的,出现问题时也难以复现和定位。如果开发人员了解并发的原理,就能在有并发问题隐患的地方妥善处理来规避风险。

并发的知识体系很庞大,涉及到内存模型、并发容器、线程池等一系列知识点,优秀并发程序对性能与活跃性也有较高的要求,因此想要吃透并发并不是一件容易的事。想要写好并发程序,不仅需要对并发的原理有所了解,更需要工程上的实践,万丈高楼平地起,下面让我们来一起探索并发编程。

一、原子性、可见性、顺序性问题

1.1 原子性

提到并发,有一个很经典的例子就是同时启动2个线程对一个数循环+1,程序如下所示。线程t1和t2都会循环1万次a++,理想状态下a最终的值应该是20000,但是运行之后a的值总是小于20000,并且每次的结果都不尽相同,这是为什么呢?

public class Test {
    public static int a = 0;
    public static void main(String[] args) {
        Runnable r = () -> {
            for (int i = 0; i < 10000; i++) {
                a++;
            }
        };
        Thread t1 = new Thread(r);
        Thread t2 = new Thread(r);
        t1.start();
        t2.start();
        try {
            // 程序本身运行在主线程, 这里需要让主线程等待t1和t2运行完再获取a的值
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("a: " + a);
    }
}

这里涉及到2个问题:

  • 一是原子性问题。先明确原子操作的定义:如果某个操作要么不执行,要么完全执行且不会被打断,则将其称为原子操作。上述程序中我们将a++当成了原子操作,而它实际由多个原子操作组成,本身并不是原子操作。
  • 二是可见性问题。线程t1修改了a的值后,t2可能感知不到。

可见性问题1.2中会讲,这里我们先讨论原子性问题。a++看起来是一个原子操作,而实际上a++需要3条CPU指令完成,这里的CPU指令才具备原子性。

指令1: 将变量a从内存加载到CPU寄存器
指令2: 寄存器修改a的值为a+1
指令3: 将结果写入内存

并发情况下,线程切换可能发生在任一CPU指令执行完的时候。例如当t1和t2一起运行时,可能出现下面的情况。

t1线程读到a的值为0
t1线程对寄存器中的值+1得到1
----------线程切换----------
t2线程从内存中读到a的值为0
t2线程对寄存器的值+1得到1
t2线程将1写入内存
----------线程切换----------
t1线程将1写入内存

可以发现在并发环境下,某个线程从内存中读取到的值可能不是最新数据,这也解释了为什么程序的结果总是小于20000。要解决这个问题,只需要将a++变为原子操作即可,通过synchronized关键字即可使某块代码具备原子性,该代码块也被称为同步代码块,如下所示。

synchronized (Test.class) {
    a++;
}

该同步代码块以Test.class作为互斥锁,任何线程在进入该代码块之前需要先获取该锁,如果获取不到则需等待当前持有该锁的线程释放锁。可以理解为,锁是用来保护共享变量的,这里的Test.class就是用来保护共享变量a同一时刻只能被一个线程访问的锁。

那么如果新增一个与a无关的共享变量b,是否也可以使用Test.class来保护呢? 答案是否定的,如果用同一个锁来保护它们,那么不管修改a或b之前都要得到Test.class这个锁,导致访问a的同时不能访问b,但这两个变量并无关联,反而降低了运行效率。

不过可以通过另一个锁来保护b,由于Java中的任何对象都可以作为锁来使用,所以可以直接新建一个final对象来保护b,如下所示。

public class Test {
    public static int b = 0;
    public static final Object lock = new Object();

    public static void main(String[] args) {
        Runnable r = () -> {
            for (int i = 0; i < 10000; i++) {
                synchronized (lock) {
                    b++;
                }
            }
        };
        ......
    }
}

1.2 可见性与CPU缓存

可见性问题是指在并发条件下,一个线程修改共享变量后,另一个线程无法感知到共享变量的变化。该问题是由CPU缓存引起的,由于CPU与内存的运行速度相差太多,为了平衡这个差距,CPU引入了高速缓存cache作为中间层,当CPU从内存中读取数据时会将其读入cache中,之后CPU不用每次读取内存,而是直接操作cache中的数据,最后在合适的时机际将cache中的数据写入内存,当然这个时机对开发人员来说是不可控的。

在单核CPU条件下,CPU整体只有一个cache,因此多线程操作的也是同一个cache,不会出现可见性问题。而在多核CPU条件下,每核CPU都对应一个cache,很有可能两个线程读写的是各自的cache,由此产生了可见性问题。

下面用一个例子说明可见性问题,子线程t1在flag为true时会一直运行,但是主线程会在500ms后将flag改为false,看起来子线程只会运行500ms,但事实是子线程会一直运行下去。换句话说,主线程修改flag的行为对子线程来说不可见。

public class Test {
    private static boolean flag = true;
    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable r1 = () -> {
            while (flag) {
                i++;
            }
            System.out.println("子线程结束");
        };
        Thread t1 = new Thread(r1);
        t1.start();
        Thread.sleep(500);
        flag = false;
        System.out.println("Main Thread结束");
    }
}

要解决这个问题,使用volatile关键字修饰flag变量即可。

volatile关键字表示一个变量是易变的,从抽象的角度明确了该变量的可见性。作为高级语言关键字,volatile屏蔽了底层硬件的不一致性,在不同的环境下,volatile关键字在底层可能具有不同的实现,但是作为高级语言的开发者,我们只需要理解volatile在抽象层面的定义即可。

之前提到,CPU高速缓存中的数据会在合适的时机被写入内存,那么上面程序中的flag变量即使不加volatile,主线程对flag变量的修改也会在一段时间后写入内存。那为什么子线程一直感知不到flag的变化呢?

我们有理由猜测,子线程在while(flag)循环中读取的一直是CPU缓存中的flag变量,而没有从内存中重新获取。为了验证这个猜测,尝试在while(flag)循环中加上一些代码,如下所示。

public class Test {
    private static boolean flag = true;
    private static int i = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable r1 = () -> {
            while (flag) {
                i++;
                try {
                    Thread.sleep(50);
                    System.out.println("......");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("子线程结束");
        };
        Thread t1 = new Thread(r1);
        t1.start();
        Thread.sleep(500);
        flag = false;
        System.out.println("Main Thread结束");
    }
}

运行后发现子线程运行约500ms后停止,表示子线程在某个时机获取到了内存中flag的值,印证了我们的猜测。这表明如果不加volatile关键字,虽然cache的值与内存的值也会同步,但同步的时机是不确定的,这也是很多并发bug难以溯源的原因。

这时候你可能会对1.1的例子产生疑惑,虽然1.1中使用synchronized关键字使a++具备了原子性,不过并没有使用volatile修饰变量a,如果a不具备可见性的话,最终的结果也应该小于预期值才对。

但实际情况是,该程序最终的结果都是正确的,这与Java内存模型(JMM)有关,内存模型保证原子操作中的变量具备可见性,第2节会阐述JMM相关内容。

1.3 顺序性与指令重排

指令重排是指编译器为了提高程序的运行效率,在不影响运行结果的前提下对CPU的指令重新排序。即使指令之间存在依赖关系,编译器也会保证运行结果不受影响。

在单线程运行环境下,指令重排确实不会影响运行结果,但在多线程环境下它是存在一定隐患的。拿双重判空的单例模式来举例,其代码如下。假设A线程和B线程同时运行到同步代码块,A线程成功获取到锁并新建Singleton,A线程释放锁后B线程抢占到锁进入同步代码块,随后B线程发现单例已经初始化就退出。如果没有指令重排,这一段代码确实没有任何问题。

public class Singleton {
    private static Singleton sInstance;
    public static Singleton getInstance() {
        if (sInstance == null) {
            synchronized(Singleton.class) {
                if (sInstance == null)
                    sInstance = new Singleton();
                }
            }
        return instance;
    }
}

但是sInstance= new Singleton()是可能会被指令重排的,正常来说这句代码的指令的执行顺序是这样的。

指令1: 分配一块内存
指令2: 在内存上初始化Singleton对象
指令3: 将内存地址赋值给sInstance变量

指令重排后的执行顺序可能是这样的。

指令1: 分配一块内存
指令2: 将内存的地址赋值给sInstance变量
指令3: 在内存上初始化Singleton对象

假设A线程执行到指令2处时被挂起,而B线程进入getInstance()方法,在第一个判空处发现sInstance不为空就会直接返回,但此时sInstance指向的内存并没有初始化,访问sInstance时很可能出现问题。因此需要使用volatile修饰sInstance来禁止相关的指令重排。

那么有没有一种更简单的实现单例的方法呢?自然是有的,如果一个单例对象在系统运行中肯定会被使用,在类初始化的时候直接新建单例的实例对象即可,不用考虑并发情况,其实现如下。

public class Singleton {
    private static class Holder {
        private static Singleton INSTANCE = new Singleton();
    }

    private Singleton() {
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }

二、Java内存模型

并发场景下的原子性、可见性和顺序性问题会导致程序的运行结果不可预测,为了解决这些问题,Java内存模型(JMM)提供了volatile和synchronized关键字,其中volatile关键字用于实现共享变量的可见性与限制重排序,synchronized关键字用于实现代码块的原子性。JMM提供了6条Happens-Before规则来描述这两个关键字的作用,以便给开发人员提供指导。

Happens-Before是指,如果操作A Happens-Before 操作B,则表示在内存顺序上,A的结果对B是可见的。Happens-Before的具体规则如下。

① 对synchronized互斥锁的解锁Happens-Before对该互斥锁的后续加锁
② 对volatile变量的写操作Happens-Before对该变量的后续读操作
③ 主线程调用子线程start()方法前的操作Happens-Before该子线程的start()方法
④ 一个线程内的所有操作Happens-Before其他线程成功join()该线程
⑤ 任意对象的默认初始化Happens-Before程序的其他操作
Happens-Before具有可传递性,如果A Happens-Before B,B Happens-Before C,则A Happens-Before C

回头看1.1,代码中使用synchronized关键字使a++具备了原子性,同时a变量也具备了可见性。这是因为互斥锁的解锁Happens-Before后续加锁,因此之后拿到互斥锁的线程都知道上个线程对a的操作结果。

三、互斥锁

互斥锁用于保证代码块的原子性,实际是用于保护一个或一系列共享变量同一时刻只能被一个线程访问。当某个线程进入同步代码块时,该线程需要先获取同步代码块的锁,退出同步代码块时则释放锁。如果线程尝试获取锁时发现锁已被占用,则该线程被挂起,直到获取锁之后再进入运行态。

Java提供了synchronized和Lock两种互斥锁的实现。synchronized是Java关键字,属于语言特性,但是在Java6之前它的效率并不高。Lock系列由并发大师Doug Lea编写,在Java5时加入Java并发包。

3.1 synchronized

3.1.1 基本使用

synchronized用于修饰方法或代码块,它隐式地实现了加锁/解锁操作,线程进入方法或代码块时会自动加锁,退出时会自动解锁。其使用示例如下。

public class Sample {
    private static final Object lock = new Object();

    // 1. 修饰代码块
    public void fun1() {
        synchronized(lock) {
            // ......
        }
    }

    // 2. 修饰非静态方法
    public synchronized void fun2() {
      // ......
    }

    // 3. 修饰静态方法
    public synchronized static void fun3() {
        // ......
    }
}  

可以发现synchronized只有在修饰代码块时才需要指定互斥锁,而修饰方法时的互斥锁是Java隐性添加的。其规则为:修饰static方法时,互斥锁为当前类的class对象,也就是例子中的Sample.class;而修饰非static方法时,互斥锁为当前的对象实例。

3.1.2 锁的选择

首先需要明确什么样的对象适合作为互斥锁,由于互斥锁是用于保护共享变量的,那么互斥锁的生命周期应该与它保护的共享变量一致,且在运行过程中不可再被赋值。

synchronized修饰方法时隐式添加的锁就遵循了这样的规则,当synchronized修饰静态方法时,它的锁为当前类的class对象,该对象在程序开始运行时就被创建,且不可更改。

以下面的程序为例,staticList作为静态变量,它的生命周期是整个程序,与互斥锁Sample.class的生命周期相同。

public class Sample {
    private static List<String> staticList = new ArrayList<>();

    public synchronized static void addString(String s) {
        staticList.add(s);
    }
}  

当synchronized修饰非静态方法时,它的锁为当前对象this。因为非静态方法用于操作非静态成员,例如下面示例中的mList,而非静态成员的生命周期就是当前对象this的生命周期。

public class Sample {
    private List<String> mList;

    public Sample() {
        mList = new ArrayList<>();
    }

    public synchronized void addString(String s) {
        mList.add(s);
    }
}  

需要注意的是,使用synchronized修饰static方法时,该类所有static方法的互斥锁都是同一个对象,所以该类所有static方法都是互斥的。如果各个static方法中需要保护的资源不一样,这样只会影响运行效率,使用synchronized修饰非静态方法时同理。

下方程序就使用了Sample.class这把锁保护了两个不同的资源,那么该如何修改呢?

public class Sample {
    private static List<String> staticList1 = new ArrayList<>();
    private static List<String> staticList2 = new ArrayList<>();
    
    public synchronized static void addString1(String s) {
        staticList1.add(s);
    }

    public synchronized static void addString2(String s) {
        staticList2.add(s);
    }
}  

一般来说,互斥锁应与被保护的资源一一对应,最简单的方法就是将被保护的对象本身作为互斥锁,如下所示。

public class Sample {
    private static final List<String> staticList1 = new ArrayList<>();
    private static final List<String> staticList2 = new ArrayList<>();

    public static void addString1(String s) {
        synchronized (staticList1) {
            staticList1.add(s);
        }
    }

    public static void addString2(String s) {
        synchronized (staticList2) {
            staticList2.add(s);
        }
    }
}

可以发现新的示例中,我们使用final修饰了staticList1和staticList2,这是为什么呢? 这涉及到对线程加锁的原理,当程序对线程加锁时,实际是在锁对象中写入了该线程的id,因此锁对象在运行期间不该被重新赋值。如果在运行期间锁被重新赋值(例如上方的staticList1指向了一个新的对象),就相当于使用多个锁保护一个资源,无法达到互斥的目的。而被final修饰的对象是必须被初始化的,且无法被重新赋值,满足作为锁的条件。

3.1.3 线程"等待-通知"机制

考虑如下程序,当fun()方法中canExecute()这个运行条件不满足时,我们可能会通过循环等待的方式,直到canExecute()方法返回true后再运行接下来的逻辑。

public class Sample {
    private boolean execute = false;
    
    public void fun() throws InterruptedException {
        while (!canExecute()) {
            Thread.sleep(10);
        }
        // ......
    }

    private synchronized boolean canExecute() {
        return execute;
    }

    public synchronized void setExecute() {
        execute = true;
    }
}

问题在于,线程睡眠的时间是一个固定的值。如果该值太大,线程无法在运行条件满足后的第一时间开始运行;如果该值太小,线程会不断地调用同步方法canExecute()判断当前的状态,浪费了运行资源。那么有没有一种方法,能够使不满足运行条件的线程进入休眠状态(该状态下线程不消耗运行资源),而在满足条件后的第一时间开始运行呢?这就是线程的"等待-通知"机制。

Java所有的对象都有wait()notify()notifyAll()这3个方法,这是对象作为互斥锁时所使用的方法,它们与synchronized共同实现了线程的"等待-通知"机制,需要注意的是,这3个方法必须在synchronized代码块中执行。下面介绍这3个方法的作用。

  1. wait(): 用于使当前线程进入休眠状态。 如果当前的运行条件未满足,可以调用互斥锁的wait()方法主动放弃互斥锁并进入休眠态,随后该线程会进入互斥锁的等待队列中,直到被其他线程唤醒。
  2. notify(): 用于唤醒互斥锁等待队列中的一个线程。 当等待队列中的线程的运行条件被满足时,可以调用notify()方法随机唤醒等待队列中的一个线程,随后被唤醒的线程去争夺互斥锁。
  3. notifyAll(): 唤醒互斥锁等待队列中的所有线程。 由于notify()方法唤醒线程时是随机的,可能唤醒的并不是真正满足运行条件的线程,因此实际开发中基本只用notifyAll(),让等待队列中的所有线程去争夺互斥锁。

举个简单的例子,代码如下所示。 线程t1运行tryExecute()方法,发现mShouldExecute为false,调用wait()主动进入休眠态。线程t2睡眠3秒后将mShouldExecute设置为true并调用notifyAll()通知等待队列中的线程,t1被唤醒后发现运行条件满足,继续执行。

public class Sample {
    private boolean mShouldExecute = false;

    public void tryExecute() throws InterruptedException {
        synchronized (this) {
            while (!mShouldExecute) {
                System.out.println("tryExecute wait...");
                wait();
            }
            realExecute();
        }
    }

    private void realExecute() {
        System.out.println("realExecute");
    }

    public void setExecute() {
        synchronized (this) {
            mShouldExecute = true;
            notifyAll();
        }
    }

    public static void main(String[] args) {
        Sample sample = new Sample();
        Thread t1 = new Thread(() -> {
            try {
                sample.tryExecute();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(3000);
                sample.setExecute();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t1.start();
        t2.start();
    }
}

上述程序中的互斥锁是当前对象this,因此在调用wait()notifyAll()方法时省略了this,写完整应该是this.wait()this.notifyAll()

在判断程序是否满足运行条件时,tryExecute()方法中使用了循环while (!mShouldExecute),这是因为线程t2调用notifyAll()后,线程t1并不会马上执行,而是要去争夺互斥锁。有可能争夺到互斥锁时,运行条件又不满足了,因此需要重新判断。

线程"等待-通知"机制可以用来实现阻塞队列(BlockingQueue)。阻塞队列是一种"生产者-消费者"模型的数据结构,当队列为空时,所有尝试获取数据的线程都会休眠,直到别的线程成功添加数据后唤醒它们;当队列已满时,所有尝试添加数据的线程都会休眠,直到获取数据成功的线程唤醒它们。

public class BlockingQueue<T> {

    private Queue<T> mQueue;
    private int mCapacity;

    public BlockingQueue(int capacity) {
        mQueue = new ArrayDeque<>(capacity);
        mCapacity = capacity;
    }

    /**
     * 尝试向队列添加数据, 如果队列已满则休眠当前线程
     */
    public void offer(T t) throws InterruptedException {
        synchronized (this) {
            while (isQueueFull()) {
                wait();
            }
            mQueue.offer(t);
            notifyAll();
        }
    }

    /**
     * 尝试获取队头数据, 如果队列为空则休眠当前线程
     */
    public T take() throws InterruptedException {
        synchronized (this) {
            while (isQueueEmpty()) {
                wait();
            }
            T result = mQueue.poll();
            notifyAll();
            return result;
        }
    }

    /**
     * 判断队列是否为空
     */
    private boolean isQueueEmpty() {
        return mQueue.isEmpty();
    }

    /**
     * 判断队列是否已满
     */
    private boolean isQueueFull() {
        return mQueue.size() == mCapacity;
    }
}

3.2 Lock

Lock用于解决synchronized在某些场景下的缺陷,在以下场景中synchronized无法实现最佳效果,但是Lock可以轻松解决。

  1. 当线程进入synchronized修饰的方法或代码块中,只有等线程执行完或者调用wait()方法才会释放锁。如果一个线程在进行耗时任务,那么其他线程都必须等待它运行完毕,无法中断。
  2. 使用synchronized保护共享变量时,在同一时刻最多只有一个线程进行读写。但是多个读线程并不冲突,如果读线程也互斥的话会影响程序效率。针对这种情况,Lock提供了读写锁ReadWriteLock。
  3. 使用synchronized实现"等待-通知"机制时,调用notifyAll()会唤醒所有阻塞的线程,无法唤醒特定的线程。例如在3.1.3的阻塞队列中,如果某个线程执行poll()方法取出了队列中的最后一个数据,随后只需要唤醒那些调用offer(T t)的线程即可,而notifyAll()会唤醒所有线程,如果调用poll()方法的线程抢占到互斥锁,也会马上发现条件不满足,然后继续休眠。

Lock本身是一个接口,针对不同的场景Lock提供了不同的实现类,接口如下所示。

    /**
     * 获取锁
     *
     * Lock的实现应该能够监测到锁的错误使用, 例如可能产生死锁或抛出异常
     * Lock的实现必须记录锁的情况和异常类型
     */
    void lock();

    /**
     * 获取锁, 除非线程被中断
     *
     * 如果当前线程在进入方法前或者在获取锁的过程中被设置为interrupted
     * 那么会抛出InterruptedException并且当前线程的中断状态会被清除
     */
    void lockInterruptibly() throws InterruptedException;

    /**
     * 如果当前锁可用, 则获取锁, 否则直接返回false
     *
     * 一个典型的用法如下所示: 
     * Lock lock = ...;
     * if (lock.tryLock()) {
     *   try {
     *     // manipulate protected state
     *   } finally {
     *     lock.unlock();
     *   }
     * } else {
     *   // 备用逻辑
     * }}
     */
    boolean tryLock();

    /**
     * 如果在给定时间内线程没有被中断且获取到锁则返回true
     * 如果在给定时间之后线程还未获取到锁, 则返回false
     */
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    /**
     * 释放锁
     */
    void unlock();

    /**
     * 返回一个绑定了当前Lock的条件变量Condition, 用于实现线程的"等待-通知"机制
     * 在某个Condition上被阻塞的线程可以被单独唤醒
     */
    Condition newCondition();

Lock作为并发包的实现,在使用上与synchronized关键字有所不同。synchronized是自动加锁/解锁的,即使在同步代码块中出现异常,JVM也能保证锁正常释放。但是Lock无法做到,在使用Lock时,需要遵守以下的范式来保证遇到异常时也能正常释放锁,否则其他线程永远得不到运行的机会。

public void fun() {
    lock.lock();
    try {
        // ......
    } finally {
        rtl.unlock();
    }
}
3.2.1 可重入锁ReentrantLock

可重入锁指的是线程可以重复获取同一把锁,示例如下所示,一个线程运行到fun1()时获取到了锁,在未释放锁的情况下调用fun2()是可以正常运行的。 synchronized关键字也是可重入锁,因为它的加锁操作本质上就是在锁这个Java对象的对象头写入当前线程的id,表示锁被当前线程占有了,自然是可重入的。

public class Sample {
    private Lock lock = new ReentrantLock();

    public void fun1() {
        lock.lock();
        try {
            fun2(); // fun2()也需要获取锁
        } finally {
            lock.unlock();
        }
    }

    public void fun2() {
        lock.lock();
        try {
            // ......
        } finally {
            lock.unlock();
        }
    }

使用synchronized关键字时,可以通过wait()notifyAll()实现线程的"等待-通知"机制,在3.1.3中通过它们实现了一个阻塞队列。但是使用wait()notifyAll()存在的问题是,只能唤醒所有休眠的线程,而无法根据当前的条件唤醒特定的线程去执行。

但是Lock解决了这个问题,Lock接口有个方法Condition newCondition()可以新建一个与当前Lock绑定的条件变量。可以通过Condition.await()方法使某个线程休眠,当该条件变量满足后,可以通过Condition.signalAll()唤醒该条件变量下休眠的线程。

下面用ReentrantLock和Condition实现阻塞队列如下,此时可以创建两个Condition,一个Condition表示队列不满,如果线程添加数据时发现队列已满,那么阻塞在该Condition上,直到有数据出队时唤醒阻塞在该条件上的线程;另一个Condition表示队列不空,如果线程获取数据时发现队列为空,则阻塞在该Condition上,直到有数据入队时唤醒阻塞在该条件上的线程。

public class BlockingQueue<T> {
    final Lock lock = new ReentrantLock();

    final Condition notFull = lock.newCondition(); // 条件: 队列不满
    final Condition notEmpty = lock.newCondition(); // 条件: 队列不空
    
    private Queue<T> mQueue;
    private int mCapacity;
    
    public BlockingQueue(int capacity) {
        mQueue = new ArrayDeque<>(capacity);
        mCapacity = capacity;
    }
    
    public void offer(T t) {
        lock.lock();
        try {
            while (isQueueFull()) {
                notFull.await();
            }
            mQueue.offer(t);
            // 入队后, 通知可以出队了
            notEmpty.signalAll();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    private boolean isQueueFull() {
        return mQueue.size() == mCapacity;
    }

    public T take() {
        T result = null;
        lock.lock();
        try {
            while (isQueueEmpty()) {
                notEmpty.await();
            }
            result = mQueue.poll();
            // 出队后, 通知可以入队了
            notFull.signalAll();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
        return result;
    }

    private boolean isQueueEmpty() {
        return mQueue.size() == 0;
    }
}
3.2.2 读写锁ReadWriteLock

使用synchronized关键字保护共享变量时,线程的读操作也会互斥,但是多个线程的读操作并不会产生并发问题。针对读写场景,Java并发包提供了读写锁ReadWriteLock,它是一个接口,如下所示。

public interface ReadWriteLock {
    Lock readLock(); // 返回读锁

    Lock writeLock(); // 返回写锁
}

ReadWriteLock的实现为ReentrantReadWriteLock,从命名可以看出,这是一个可重入锁。当调用readLock()writeLock()获取读锁或写锁时,返回的是ReentrantReadWriteLock中的内部变量readerLock和writerLock,它们都是Lock接口的实现类。

public class ReentrantReadWriteLock
        implements ReadWriteLock, java.io.Serializable {
    /** Inner class providing readlock */
    private final ReentrantReadWriteLock.ReadLock readerLock;
    /** Inner class providing writelock */
    private final ReentrantReadWriteLock.WriteLock writerLock;
    /** Performs all synchronization mechanics */
    final Sync sync;

    // 默认新建非公平锁
    public ReentrantReadWriteLock() {
        this(false);
    }

    public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
    }

    public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
    public ReentrantReadWriteLock.ReadLock  readLock()  { return readerLock; }

    ......
}

在使用之前先来看一下ReadWriteLock的特性:

  1. 读和读之间不互斥,读和写之间互斥,写和写之间互斥。这意味着没有写锁时,读锁可以被多个线程持有。
  2. 读写锁只适用于读多写少的场景,例如某个很少被修改但是经常被搜索的数据(如条目)就适合使用读写锁。如果写操作较为频繁,那么数据大部分时间都被独占锁占据,并不会提升并发性能。
  3. 持有读锁的线程无法直接获取写锁,但是持有写锁的线程可以获取读锁,其他线程无法获取读锁。换句话说,读锁无法升级为写锁,写锁可以降级为读锁。
  4. 读锁和写锁都实现了Lock接口,因此它们都支持tryLock()lockInterruptibly()等方法。但是只有写锁支持Condition,读锁不支持使用条件变量。

使用ReadWriteLock的一个简单示例如下。

public class Sample<T> {
    private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private T mData;

    public T read() {
        readWriteLock.readLock().lock();
        try {
            return mData;
        } finally {
            readWriteLock.readLock().unlock();
        }
    }
    
    public void write(T t) {
        readWriteLock.writeLock().lock();
        try {
            mData = t;
        } finally {
            readWriteLock.writeLock().unlock();
        }
    }
}
3.2.3 支持乐观读的StampedLock

StampedLock具有三种支持读/写功能的模式,它的状态由版本(stamp)和当前的模式组成。StampedLock获取锁的方法会返回一个stamp,该值用于表示当前锁的状态,在释放锁和转换锁时需要stamp作为参数,如果它与锁的状态不匹配就会失败。StampedLock支持的三种模式为写、读和乐观读。

  1. 写模式,类似读写锁的写锁。线程调用StampedLock.writeLock()后独占访问数据,该方法返回一个stamp表示锁的状态,调用StampedLock.unlockWrite(stamp)解锁时需要该stamp。 tryWriteLock()tryWriteLock()方法也会返回stamp,这2个方法获取锁失败时会返回0。当锁处于写模式时无法获得读锁,所有乐观读的验证都将失败。
  2. 读模式,类似读写锁的读锁。线程调用StampedLock.readLock()后进行读操作,多个线程的读操作可同时进行。读锁加锁时也会返回stamp,用于解锁时调用StampedLock.unlockRead(stamp)使用。
  3. 乐观读模式,该模式是读锁的弱化版本,使用时不需要获取锁。乐观读的思想是,假设读的过程中数据并未被修改。乐观读避免了锁的争用并提高了吞吐量,但是它的正确性无法保证,因此读到结果后需要验证在乐观读时是否有线程获取了写锁。 调用StampedLock.tryooptimisticread()后可进行乐观读,如果当前不处于写入模式则返回非零stamp,乐观读结束后调用validate(stamp)判断结果是否有效。

StampedLock的使用依赖于对所保护的数据、对象和方法的了解,如果乐观读的结果未经验证,不应该用于无法忍受潜在不一致的方法。StampedLock是不可重入的,因此获取了锁的线程不应该调用其他尝试获取锁的方法。

来看官方提供的例子,Point类有3个方法,move(...)方法描述了写模式的一般流程;distanceFromOrigin()描述了乐观读模式的流程,如果乐观读的数据失效,则获取读锁进行读操作;moveIfAtOrigin(...)描述了StampedLock的锁升级,在平时开发中,锁的升级要慎重使用。

public class Point {
    private double x, y;
    private final StampedLock sl = new StampedLock();

    // 写锁
    void move(double deltaX, double deltaY) {
        long stamp = sl.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    // 乐观读
    double distanceFromOrigin() {
        long stamp = sl.tryOptimisticRead();
        double currentX = x, currentY = y;
        if (!sl.validate(stamp)) {
            // 如果数据已过时, 则获取读锁进行读操作
            stamp = sl.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

    // 锁的升级
    void moveIfAtOrigin(double newX, double newY) {
        long stamp = sl.readLock();
        try {
            while (x == 0.0 && y == 0.0) {
                long ws = sl.tryConvertToWriteLock(stamp);
                if (ws != 0L) {
                    // 需要将升级后的stamp值更新
                    stamp = ws;
                    x = newX;
                    y = newY;
                    break;
                } else {
                    sl.unlockRead(stamp);
                    stamp = sl.writeLock();
                }
            }
        } finally {
            sl.unlock(stamp);
        }
    }
}

四、线程安全的变量

互斥锁是用于保护共享变量的,但如果一个变量不会被多个线程同时访问也就没有保护的必要了,例如方法内的局部变量。根据Java虚拟机规范,每个线程的内存中都包含程序计数器、虚拟机栈以及本地方法区这3块内存,其中虚拟机栈用于存储线程的方法调用,当调用某个方法时,线程会在虚拟机栈中压入一个栈帧;等该方法调用完毕时,对应的栈帧就会从虚拟机栈中弹出,而局部变量表就保存在栈帧中。

虚拟机栈.png

在大部分Java虚拟机的实现中,虚拟机栈的大小是固定的,只有少数JVM的虚拟机栈是可以扩展的。虚拟机栈大小固定的情况下,如果进行一个深度过大的递归调用时,就可能产生StackOverflow异常,就是因为虚拟机栈没有足够的空间去存储一个栈帧了。

当然共享变量也不一定是线程不安全的,下面介绍3种线程安全的变量,它们实现线程安全的方式各不相同。

4.1 不可变变量

并发环境下对共享变量的读写会出现问题,但是只读不写就没有并发问题。如果一个共享变量在初始化后不会再改变,它就是线程安全变量。我们知道被final关键字修饰的变量是必须被初始化且初始化后不再改变的,那么被final修饰的共享变量是否就是线程安全的呢?我们以两个例子来说明。

先来看这个例子,类中有2个共享变量分别是String和int类型的,它们在构造方法中会被初始化,并且不可更改,因此是线程安全的。

public class Sample {
    private final String mString;
    private final int mInt;
    
    public Sample(String s, int i) {
        mString = s;
        mInt = i;
    }
    
    public String getString() {
        return mString;
    }
    
    public int getInt() {
        return mInt;
    }
}

来看第二个例子,虽然Sample中将mTest修饰为final变量,但是这里的final只是表示mTest对象的内存地址不可变,对象中的值是可变的,所以通过getTest()获取到mTest变量后,是可以对mTest中的String和int重新赋值的。而Test类中的String和int变量不是final变量,所以Sample不是线程安全的。

public class Sample {
    private final Test mTest;
    
    public Sample(Test test) {
        this.mTest = test;
    }
    
    public Test getTest() {
        return mTest;
    }

    static class Test {
        public String s;
        public int i;
    }
}

如果想要让类的对象是不可变的,那么类的属性应该全部修饰为final,如果某个属性不是基本类型,那么该类也应将所有属性修饰为final。

需要注意的是,String类型是不可变的,当调用String的subString()replace()方法时不是修改原来的String,而是生成一个新的String对象。但是StringBuilder和StringBuffer是可变的,调用append()方法时是在原对象上操作的。

4.2 原子类变量

在1.1中我们解决a++原子性问题的方法是使用synchronized,但是加锁/解锁操作以及线程切换都是比较消耗性能的。但是有一种变量叫原子类变量,它的方法本身就是原子操作,这是通过CPU指令支持的,因此具有很好的效率。

4.2.1 基本变量原子类

原子类中的AtomicBoolean, AtomicInteger, AtomicLong对应基本类型的Boolean, Integer, Long,这3个原子类共有的方法如下。

// 只有当前原子变量的值是expect才更新为update, 成功返回true, 失败返回false
boolean compareAndSet(expect, update);
// 将原子变量的值更新为newValue并返回更新前的值
boolean/int/long getAndSet(newValue);
// 将原子变量的值更新为newValue, 但是对其他线程并不一定立即可见
void lazySet(newValue);

当然AtomicInteger和AtomicLong还有另外的方法,这些方法通常成对出现,例如getAndIncrement()incrementAndGet(),它们的功能都是自增,唯一的不同就在于返回的是更新前的值还是更新后的值。如果1.1的例子使用原子类来实现,只需要将a改为原子变量a = new new AtomicInteger(0)并将a++改为a.getAndIncrement()就能得到正确的结果。

// 增加delta, 返回更新后的值, getAndAdd()与其作用相同但是返回更新前的值
int/long addAndGet(int delta);
// 通过IntBinaryOperator对prev和x计算得到新的值, IntBinaryOperator的方法必须无副作用
int/long accumulateAndGet(x, IntBinaryOperator);
// 通过IntUnaryOperator对prev计算得到新的值, IntUnaryOperator的方法必须无副作用
int/long updateAndGet(IntUnaryOperator updateFunction);

原子类通过CPU提供的CAS指令实现了原子性,该指令本身具有原子性。CAS指Compare And Swap,即比较并交换,该指令先比较共享变量的值与期望值,只有这两个值相等才将共享变量的值更新。原子类的compareAndSet(expect, update)方法就是通过native的compareAndSwapXXX(...)方法实现的。

CAS+自旋是并发编程的一大利器,在Java并发包中得到了广泛的应用,"自旋"实际上就是通过循环不断尝试CAS操作直到成功。例如AtomicInteger的getAndAdd(delta)方法,它实际调用Unsafe中的getAndAddInt(Object var1, long var2, int var4)方法,如下所示。由于在进行CAS指令之前,原子类的值可能被其他线程修改,因此需要循环获取原子变量当前的值并调用compareAndSwapInt(...)直到成功。

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
}
4.2.2 原子数组类

数组原子类包括AtomicIntegerArray, AtomicLongArray和AtomicReferenceArray,它们的方法都大同小异。以AtomicIntegerArray为例,它有2个构造方法,一个传入数组长度,数组中所有的值被初始化为0,另一个构造方法直接传入一个int数组。

public AtomicIntegerArray(int length) {
    array = new int[length];
}

public AtomicIntegerArray(int[] array) {
    // Visibility guaranteed by final field guarantees
    this.array = array.clone();
}

相比于AtomicInteger,AtomicIntegerArray的方法在使用时只是多传入了数组下标参数i,看看下面的方法,是不是似曾相识?AtomicLongArray同理。 AtomicReferenceArray是原子引用的数组,接下来的4.2.3会讲解原子引用AtomicReference,使用原子数组类时只需加上下标即可。

boolean compareAndSet(int i, int expect, int update);
int getAndAdd(int i, int delta);
int getAndIncrement(int i);
......
4.2.3 原子引用类

引用原子类为AtomicReference,那么它是将改变引用这个操作封装成了原子操作吗?当然不是!因为改变引用这个操作本身就是原子的,对引用赋值只需要一条指令,它不会被CPU打断。 这点也可以从AtomicReference的set(V newValue)方法看出来,该方法只是对引用赋值。

public final void set(V newValue) {
    value = newValue;
}

因此AtomicReference的价值就在于它的CAS指令,用于防止其他线程随意篡改引用的值。

boolean compareAndSet(V expect, V update);

使用AtomicReference要注意是否存在ABA问题,先来解释一下什么是ABA问题。 之前使用AtomicInteger时,我们将原子类的值当成了它的状态,在调用compareAndSet(A, update)时只要原子类的值与A相等就认为它没有被修改过,但这样的判断是存在隐患的。如果T1线程在调用compareAndSet(A, update)之前,T2线程将值从A改为B,再从B改为A,虽然原子类的值没变,但是它的状态已经发生了变化。随后T1线程执行CAS时发现值未变就会继续执行,但是原子类状态上的变化会引起一些意想不到的错误。

维基百科的CAS词条[参考2]描述了一个无锁栈可能出现的ABA问题。先解释一下无锁栈,如果需要实现一个支持并发的栈,有哪些方式可以实现呢? 显而易见的实现方式是在pop()push()方法上加锁,这种实现简单粗暴,不会出错,但是效率较低。更好的方式是使用CAS+自旋,该实现不需要加锁,通过CAS+自旋实现的数据结构也被称为无锁结构。

以无锁栈为例,其数据结构为链表,通过一个指针head指向栈顶,栈的push和pop操作都依赖head完成,当head指向null时栈为空。

无锁栈.png

为了保证线程对head操作时的原子性,需要将head定义为原子引用AtomicReference<StackNode>,无锁栈代码如下所示。

下面以push操作为例,介绍CAS+自旋是如何工作的。如下方代码的push()方法所示,新建pushNode后,将pushNode.next指向head,并通过CAS将head指向pushNode。但是执行CAS之前head可能会被修改,因此如果CAS执行失败,就需要重新为pushNode赋值并再次尝试CAS直到成功。

public class ConcurrentStack {
    private AtomicReference<StackNode> headReference;

    public ConcurrentStack() {
        headReference = new AtomicReference<>(null);
    }

    public void push(Integer i) {
        StackNode pushNode = new StackNode(i);
        while (true) {
            StackNode head = headReference.get();
            pushNode.next = head;
            // 其他线程可能在此处修改了head的值
            if (headReference.compareAndSet(head, pushNode)) {
                break;
            }
        }
    }

    public StackNode pop()  {
        while (true) {
            StackNode head = headReference.get();
            if (head == null) {
                return null;
            }
            StackNode newHead = head.next;
            // 其他线程可能在此处修改了head的值
            if (headReference.compareAndSet(head, newHead)) {
                return head;
            }
        }
    }
    
    private static class StackNode {
        public int value;
        public StackNode next;
        
        public StackNode(Integer i) {
            value = i;
        }
    }
}

那么在什么情况下,无锁栈会发生ABA问题呢?假设当前栈如下所示。

ABA-初始状态.png

此时T1线程调用pop(),但是在执行CAS之前该线程被挂起,也就是停在了上述pop()方法的注释处。此时head指向StackNode4,newHead指向StackNode3。

但是这时T2线程调用了2次pop()使得StackNode4和StackNode3出栈,随后新建另一个值为4的节点StackNode4*并push入栈。可以看到此时StackNode4和StackNode3是游离在栈外的,但是StackNode3.next还是指向StackNode2的。T1线程中的临时变量head指向StackNode4,临时变量newHead指向StackNode3。

ABA-T2操作后.png

如果由于某种原因,StackNode4*的地址与T1线程中的head(也就是StackNode4)相等,那么T1线程获得时间片后会认为head并未发生变化并执行CAS,即使此时栈的结构和T1挂起前已经不一样了。T1执行完之后栈如下所示,该结果与预期的并不一致。

ABA-T1操作后.png

问题在于,StackNode4*的地址可能与StackNode4一样吗?换个问法,新的对象可能与之前的对象地址相同吗?当然有可能!主要是以下2种情况:

① 该类使用享元模式,所有值一样的对象实际都是一个。但是StackNode不可能使用享元模式,因为节点的next值都是不同的。
② 类使用了对象缓存池,以StackNode为例,被pop的节点会被缓存,需要新节点时先从缓存池中获取。

了解了ABA问题产生的原因后,我们通过代码来模拟。由于该场景难以模拟,因此选择在栈的pop()方法中进行延时,保证其他线程在CAS之前能够修改head,修改后的ConcurrentStack如下。

public class ConcurrentStack {

    省略其他代码......

    public StackNode pop(long t)  {
        while (true) {
            StackNode head = headReference.get();
            if (head == null) {
                return null;
            }
            StackNode newHead = head.next;
            try {
                Thread.sleep(t);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (headReference.compareAndSet(head, newHead)) {
                return head;
            }
        }
    }
}

然后修改StackNode类,通过ConcurrentHashMap缓存被回收的StackNode对象,获取StackNode对象统一通过obtain(Integer i)方法。

public class StackNode {

    public int value;
    public StackNode next;
    private static ConcurrentHashMap<Integer, StackNode> mCache = new ConcurrentHashMap<>();

    public static StackNode obtain(Integer i) {
        StackNode stackNode = mCache.remove(i);
        if (stackNode == null) {
            stackNode = new StackNode(i);
        }
        return stackNode;
    }

    public static void recycle(StackNode stackNode) {
        mCache.putIfAbsent(stackNode.value, stackNode);
    }

    private StackNode(Integer i) {
        value = i;
    }
}

随后运行以下代码测试,就会发现无锁栈最后的结果与预期不符。代码中的注释详细描述了每一步的运行流程,不再赘述。

public class Main {

    public static void main(String[] args) throws InterruptedException {
        ConcurrentStack concurrentStack = new ConcurrentStack();
        // 初始化栈为 StackNode4->StackNode3->StackNode2->StackNode1->StackNode0
        for (int i = 0; i < 5; i++) {
            concurrentStack.push(i);
        }
        // 开启 popThread, 在 CAS 前 sleep 一段时间, 让另一个线程去修改栈
        Thread popThread = new Thread(() -> {
            concurrentStack.pop(1000);
        });
        // 开启 abaThread 先 pop StackNode4 和 StackNode3, 并回收 StackNode4
        // 再 push StackNode4, 正常情况下栈应为 4->2->1->0
        // 但是 popThread 在休眠后继续执行 CAS 时发现栈头还是 StackNode4, 误以为栈未发生变化
        // 因此 popThread 将 headReference 赋值给已经被 pop 的 StackNode3
        // 而 StackNode3.next 指向 StackNode2, 最后栈为 3->2->1->0
        Thread abaThread = new Thread(() -> {
            StackNode.recycle(concurrentStack.pop(0));
            concurrentStack.pop(0);
            concurrentStack.push(4);
        });
        popThread.start();
        Thread.sleep(500);
        abaThread.start();
        popThread.join();
        // 运行完, 查看栈的结果
        StackNode s;
        System.out.print("当前栈为: ");
        while ((s = concurrentStack.pop(0)) != null) {
            System.out.print(s.value + " -> ");
        }
    }
}

针对这个问题,并发包中自然也提供了解决方案,那就是AtomicStampedReference,该类通过stamp记录原子类当前的版本,每次修改都会更新版本。

4.3 ThreadLocal

ThreadLocal也被称为线程本地变量,它为各个线程都存储了一个变量。每个线程调用ThreadLocal.set(value)ThreadLocal.get()时操作的都是当前线程对应的变量,因此ThreadLocal是线程安全的。

4.3.1 基本使用

Java提供了一个例子,通过ThreadLocal为每个线程设置一个ID,代码如下所示。

public class ThreadId {
    // 通过原子变量为线程设置ID
    private static final AtomicInteger nextId = new AtomicInteger(0);
    // 重写ThreadLocal的initialValue()方法, 用于初始化各线程对应的变量
    private static final ThreadLocal<Integer> threadId = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return nextId.getAndIncrement();
        }
    };

    // Returns the current thread's unique ID, assigning it if necessary
    public static int get() {
        return threadId.get();
    }
}

在新建ThreadLocal时重写了它的initialValue()方法,该方法的作用是,如果某个线程调用ThreadLocal.get()时发现值为null,就会调用initialValue()初始化该线程对应的变量。

    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

    private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }

ThreadLocal还提供了一个静态的构造方法withInitial(Supplier<? extends S> supplier),也用于为ThreadLocal的每个线程设置初始值。

ThreadLocal<Integer> threadId = ThreadLocal.withInitial(new Supplier<Integer>() {
    @Override
    public Integer get() {
        return nextId.getAndIncrement();
    }
});

ThreadLocal.withInitial(...)返回的是SuppliedThreadLocal对象,它继承了ThreadLocal并重写了initialValue()方法,其实与第一种初始化方法没什么区别。

    static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {
        private final Supplier<? extends T> supplier;

        SuppliedThreadLocal(Supplier<? extends T> supplier) {
            this.supplier = Objects.requireNonNull(supplier);
        }

        @Override
        protected T initialValue() {
            return supplier.get();
        }
    }
4.3.2 ThreadLocal实现原理

在ThreadLocal中,线程和数据是一对一的关系,这种对应关系很容易让我们联想到Map。直观上来看,似乎是ThreadLocal中维护了一个Key为线程,Value为数据的Map。这样当然也能实现ThreadLocal,不过在这种实现下,ThreadLocal成为了线程数据的持有者,并且持有Thread的引用,这种实现很容易造成内存泄漏。

而在Java的实现中,Thread才是数据的持有者,ThreadLocal是线程本地变量的管理者。Thread中持有一个ThreadLocalMap类型的Map用于保存该线程的本地变量。

public class Thread {
    ......
    ThreadLocal.ThreadLocalMap threadLocals = null;
    ......
}

ThreadLocalMap是ThreadLocal的内部类,它是一个专门设计出来,用于维护线程本地变量的Map,它的Key为ThreadLocal的弱引用,Value则是数据。问题在于,为什么要专门实现一个Map来维护线程本地变量,难道原生的HashMap不满足需求吗?下面让我们带着问题去分析ThreadLocalMap的源码。

①. 成员变量和构造方法 ThreadLocalMap通过Entry[] table保存所有的实体。实体Entry继承自ThreadLocal的弱引用,Entry中的value保存当前线程与ThreadLocal关联的变量。我们知道,当一个对象只有弱引用指向它时,它就会被GC回收。因此ThreadLocal本身不会内存泄漏。

    static class ThreadLocalMap {

        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

        private static final int INITIAL_CAPACITY = 16; // 初始容量, 容量必须是2的倍数

        private Entry[] table; // Hash表

        private int size = 0; // Hash表当前Entry的数量

        private int threshold; // size达到这个数量时扩容

        // 根据容量计算下一个索引
        private static int nextIndex(int i, int len) {
            return ((i + 1 < len) ? i + 1 : 0);
        }

        // 根据容量计算上一个索引
        private static int prevIndex(int i, int len) {
            return ((i - 1 >= 0) ? i - 1 : len - 1);
        }

        // ThreadLocalMap是懒加载的,只有当线程需要保存本地变量时
        // 才会新建ThreadLocalMap并在构造方法传入第一个变量的key和value。
        ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            table = new Entry[INITIAL_CAPACITY];
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
            table[i] = new Entry(firstKey, firstValue);
            size = 1;
            setThreshold(INITIAL_CAPACITY);
        }
        ......
    }

②. 保存数据 ThreadLocl.set(value)方法如下,首先获取当前线程的ThreadLocalMap,如果存在直接调用ThreadLocalMap的set(key, value)方法,如果不存在就新建。

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

下面来看ThreadLocalMap是如何保存数据的,它使用线性探测法解决Hash冲突。保存Entry时先计算key的hash值,再通过index = hash & (len - 1)得到Entry应保存的位置。如果table[index]为空则在该位置保存Entry,如果不为空说明出现hash冲突,对index向后顺移直到table[index+n] (n=1,2,3...)为空。使用线性探测法时,如果Hash冲突较多,它的效率会大幅下降。

线性探测法.png

ThreadLocalMap的set(ThreadLocal<?> key, Object value)方法如下,该方法描述了使用线性探测法时添加数据的整体逻辑。

    private void set(ThreadLocal<?> key, Object value) {
        Entry[] tab = table;
        int len = tab.length;
        int i = key.threadLocalHashCode & (len-1);
        // 从位置i开始向后遍历, 直到table[i]为空
        for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
            ThreadLocal<?> k = e.get();
            // 当前key已经存在, 则保存新的value即可
            if (k == key) {
                e.value = value;
                return;
            }
            // key为null说明ThreadLocal已经被GC回收了
            // 需要用新的数据替代之前已经过期的
            if (k == null) {
                // replaceStaleEntry()方法做了大量的工作, 下面会详细分析
                replaceStaleEntry(key, value, i);
                return;
            }
        }
        // 遍历到table[i]为空也没有发现相同的key或过期的key
        // 因此需要新建一个Entry放置在当前位置
        tab[i] = new Entry(key, value);
        int sz = ++size;
        // 如果数量达到了threshold则进行扩容并重新hash
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
    }

接着来看replaceStaleEntry(key, value, staleSlot)方法,该方法用于将要插入的数据放到合适的位置,并清除Hash表中过期的key和value。

    private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
        Entry e;
        // 向前遍历, 找到最前面的Entry不为空但是key被回收的位置
        int slotToExpunge = staleSlot;
        for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
            if (e.get() == null)
                slotToExpunge = i;

        // 从staleSlot下一个位置开始向后遍历
        for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();
            // 如果找到了与当前key相同的Entry
            // 需要将其与staleSlot位置上的Entry对换, 这样能保证Hash表的顺序
            if (k == key) {
                e.value = value;
                tab[i] = tab[staleSlot];
                tab[staleSlot] = e;
                // slotToExpunge == staleSlot表示向前遍历时没有找到过期的key
                if (slotToExpunge == staleSlot)
                    slotToExpunge = i;
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                return;
            }

            // slotToExpunge == staleSlot表示向前遍历时没有找到过期的key
            // 这里找到的是staleSlot后的第1个过期的位置, 将其赋值给slotToExpunge
            // staleSlot位置本身不用清除, 因为要在该位置上放置新插入的数据
            if (k == null && slotToExpunge == staleSlot)
                slotToExpunge = i;
        }

        // 如果当前要插入的key不存在, 那么新建一个Entry放到staleSlot位置上
        tab[staleSlot].value = null;
        tab[staleSlot] = new Entry(key, value);

        // slotToExpunge != staleSlot表示有过期的key, 需要将它们清除
        if (slotToExpunge != staleSlot)
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
    }

这里通过一个例子来分析replaceStaleEntry(key, value, staleSlot)方法的运行过程,为了便于分析,假设使用index = key % 10来计算数据存放的位置。初始时情况如下图,假设当前调用ThreadLocalMap.set(key, value)方法插入一个key为13的数据,该数据的index应为3,发现该位置上的key已经过时,则调用replaceStaleEntry(key, value, staleSlot)方法。

首先向前遍历,找到最前面的Entry不为空但是key已经过时的位置,示例中就是index为1的位置,因此slotToExpunge为1。

replaceStaleEntry1.png

随后向后遍历,尝试寻找key与13相等的位置,发现table[4]符合条件,随后将table[4]与staleSlot位置的数据table[3]交换,得到结果如下。 这里交换的意义在于维持线性探测法的特性,如果不交换的话,table[3]上的数据之后会被清除,就不满足线性探测法的规律了。因为之后查找key为13的数据的话,定位到table[3]会发现为空,而实际上key为13的数据在table[4]上。

replaceStaleEntry2.png

如果在replaceStaleEntry(key, value, staleSlot)方法中找不到key与要插入数据相等的位置,就在staleSlot位置存放要插入的数据。将数据存放到合适的位置后,最后调用expungeStaleEntry(slotToExpunge)将Hash表中过期的数据清除。

    private int expungeStaleEntry(int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
        // 清除staleSlot位置上的数据
        tab[staleSlot].value = null;
        tab[staleSlot] = null;
        size--;

        // 向后遍历, 对各个位置上的数据重新hash, 直到Entry为null
        Entry e;
        int i;
        for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();
            if (k == null) {
                e.value = null;
                tab[i] = null;
                size--;
            } else {
                int h = k.threadLocalHashCode & (len - 1);
                if (h != i) {
                    tab[i] = null;
                    while (tab[h] != null)
                        h = nextIndex(h, len);
                    tab[h] = e;
                }
            }
        }
        return i;
    }

③. 获取数据 ThreadLocal获取数据的逻辑比较简单,直接调用了ThreadLocalMap的getEntry(key)方法。

    private Entry getEntry(ThreadLocal<?> key) {
        int i = key.threadLocalHashCode & (table.length - 1);
        Entry e = table[i];
        if (e != null && e.get() == key)
            return e;
        else
            return getEntryAfterMiss(key, i, e);
    }

如果直接key直接命中第一个数据,那么直接返回,否则调用getEntryAfterMiss()方法。方法内遵循线性探测法去寻找对应的Entry,在发现过时的数据时调用expungeStaleEntry()方法进行清理,直到找到对应key的Entry或遍历到空数据。

    private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
        Entry[] tab = table;
        int len = tab.length;
        while (e != null) {
            ThreadLocal<?> k = e.get();
            if (k == key)
                return e;
            if (k == null)
                expungeStaleEntry(i);
            else
                i = nextIndex(i, len);
            e = tab[i];
        }
        return null;
    }
4.3.3 预防ThreadLocal内存泄漏

ThreadLocalMap内部使用弱引用指向ThreadLocal,当ThreadLocal被回收时,map中对应的数据过时,Entry和value会残留在map中造成内存泄露。虽然在ThreadLocalMap下一次添加或删除数据时就可能被清理,但也可能一直留在map中。

因此当线程不需要某个ThreadLocal时需要手动调用一下ThreadLocal.remove()方法,该方法最终会调用ThreadLocalMap的remove(ThreadLocal<?> key)方法去清理对应的Entry。

    private void remove(ThreadLocal<?> key) {
        Entry[] tab = table;
        int len = tab.length;
        int i = key.threadLocalHashCode & (len-1);
        for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
            if (e.get() == key) {
                e.clear();
                expungeStaleEntry(i);
                return;
            }
        }
    }

由于掘金有字数限制,所以分上下篇。