Java多线程

Java 提供了多种方式来启动线程

  • new Thread() :通过继承 Thread 或 实现 Runnable 接口

  • 通过 FutureTask 执行 Callable 接口实现类

使用线程

继承 Thread

这种方式需要继承 Thread 类并重写 run 方法,一个线程只能实现同一种功能;

class TestThread extends Thread {
    @Override
    public void run() {
        System.out.println("Running");
    }

    public static void main(String[] args) {
        TestThread thread = new TestThread();
        thread.start();
    }
}

实现 Runnable 接口

实现 Runnable 接口并实现 run 方法,一个线程可以执行多种 Runnable 实例对象;

class TestRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Running");
    }

    public static void main(String[] args) {
        Thread thread = new Thread(new TestRunnable());
        thread.start();
    }
}

实现 Callable 接口

通过实现 Callable 接口可以实现带有返回值的异步任务;

  • 需要使用 FutureTask 传入该 Callable 实现类

  • FutureTask 传入 Thread 执行,最终通过 FutureTask#get() 方法获取返回结果;

class TestCallable implements Callable<Integer> {

    @Override
    public Integer call() throws Exception {
        Thread.sleep(1000);
        return 10;
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        TestCallable testCallable = new TestCallable();

        FutureTask<Integer> futureTask = new FutureTask<>(testCallable);
        new Thread(futureTask).start();

        Thread.sleep(1100); // waiting for task completed
        int value = futureTask.get();
        System.out.println(value);
    }
}

使用 Thread

Thread 的 start() 方法才是真正启动线程的方法,内部会调用底层的 start0() 方法创建线程相关的资源

Java 的线程是直接映射到操作系统的内核线程上的,实际的线程是操作系统负责管理

优先级

Thread#setPriority(int) 可以设置线程的优先级(1~10,默认5),10最高优先级

优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级的线程调度更频繁(但是不能通过设置优先级来保证高优先级的线程一定先执行)

守护线程

Java的线程分为用户线程(User Thread) 和 守护进程 (Daemon Thread)

  • 用户线程就是普通的线程,只有 run 方法执行完毕才会退出;

  • 守护线程会在 Jvm 中所有用户线程退出后才会退出(自动结束生命周期);

class TestDaemonThread{
    public static void main(String[] args) {
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("Jvm Stopped");
        }));

        Thread userThread = new Thread(() -> {
            while (true) {
                System.out.println("User Thread running");
                if (Thread.currentThread().isInterrupted()) {
                    break;
                }
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    break;
                }
            }
        });
        userThread.start();
        Thread daemonThread = new Thread(() -> {
            while (true) {
                System.out.println("Daemon Thread running");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        daemonThread.setDaemon(true);
        daemonThread.start();
        System.out.println("Main Thread stopped");
//        userThread.interrupt();
    }
}
// 上面的运行结果会在主线程退出后继续运行;

Jvm 中主线程退出并不会导致 Jvm 跟着退出;

Jvm 会在所有线程退出后自动退出;

Thread#setDaemon(boolean) 可以设置线程是否为守护线程(默认为用户线程)

线程状态

Thread.State 枚举类定义了线程的 6 中状态:

  • NEW 初始:新创建了一个线程,但是还没有调用 start() 方法;

  • RUNNABLE 运行,包括就绪 (Ready) 状态和运行中 (Running) 状态;

    • 就绪状态:线程调用了 start() 方法后 等待 获取CPU的使用权的状态;

    • 运行中状态:线程获取到CPU的使用权执行代码

  • BLOCKED 堵塞:线程因获取锁而堵塞;(锁被其他线程占用)

  • WAITING 等待:进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)

  • TIMED_WAITING 超时等待:与 WAITING 不同的时,该状态可以在指定的时间后自行返回;

  • TERMINATED 终止:该线程已经执行完毕;

image-njrq.png

就绪状态

  • 调用 start() 方法后,线程进入就绪状态;

  • 当前线程的CPU时间片用完,调用当前线程的 yield() 方法,进入就绪状态;

  • 线程的 sleep() 方法结束、join() 方法结束、拿到对象锁,都会进入就绪状态;

运行中状态:线程调用程序从可运行池中选择一个线程进行运行;(是线程进入运行中状态的唯一一种方式);

等待状态:处于这种状态的线程不会被分配CPU执行时间,需要被显式唤醒,否则会处于无限等待的状态;

超时等待状态:处于这种状态的线程不会被分配CPU执行时间,不过不需要无限期等待,在到达超时间后会自动唤醒;

终止线程

线程终止原因:

  • run()方法执行完毕后正常退出

  • run() 方法抛出的异常没有被捕获处理,导致线程异常终止;

  • 对线程调用 stop() 方法强制终止(该方法已被废弃,因为该方法强制释放线程持有的所有锁,可能会出现问题)

调用 Thread#interrupt() 方法可以通知对应线程中断(注意仅仅只是通知,而需要线程响应该通知)

  • 当线程处于循环等待消息时,可以使用 Thread.currentThread().isInterrupted() 来作为循环的终止条件

  • 当线程使用了 Thread.sleep(long)Thread.wait() 等响应中断的方法时,这些方法会抛出 InterruptedException 异常;

除此之外,还可以给线程中设置标志位,标识该线程是否需要继续运行(与上面的 interrupt() 方法实现类似)

需要使用 volatile 关键字修饰,保证该变量在线程之间的可见性;

class InterruptedThread extends Thread {
	// 标志位
    volatile boolean running = false;

    @Override
    public synchronized void start() {
        super.start();
        running = true;
    }

    @Override
    public void run() {
        while (running) {
            // actions
        }
    }
}

使用线程池

线程的创建和销毁是一个资源开销大的操作,需要操作系统资源(线程资源、栈空间等);

可以使用线程池复用线程,来避免线程的反复创建,节省资源开销;

基本原理:

  • 线程池在提交任务时检查当前的线程是否达到指定数量

    • 如果没有则创建线程并执行该任务;(准确来说先进入队列再被执行)

    • 如果满了则会进入等待队列等待可用线程;

    • 某些线程池在等待队列满了后会额外创建线程进行执行;

  • 线程池创建的线程,其 run() 方法是不断获取队列中的任务,如果拿到了则执行,否则堵塞等待;

Executors 提供了多种线程池的静态创建方法:

  • newFixedThreadPool :创建固定数量线程的线程池

  • newSingleThreadExecutor :创建只有一个线程的线程池

  • newCachedThreadPool :创建会复用线程的线程池

  • newScheduledThreadPool :创建支持延迟执行任务的线程池

  • newWorkStealingPool :创建有多个并行队列的线程池

同时可以自己自定义线程池,自己创建 ThreadPoolExecutor 对象并传入对应参数:

  • int corePoolSize :指定线程池的始终存活的线程数量(即使线程空闲也持有)

  • int maximumPoolSize :指定线程池中最多可以持有的线程数量

  • long keepAliveTime :当前线程池的线程数量大于核心线程数量时,线程的最大存活数量;

  • BlockingQueue<Runnable> workQueue :外部实现的工作队列;

  • TimeUnit unit :是 keepAliveTime 的时间单位;

  • ThreadFactory threadFactory :线程工厂(默认的线程工厂为 DefaultThreadFactory);

  • RejectedExecutionHandler handler :当提交的任务被拒绝时的处理器(默认为抛出 RejectedExecutionException 异常);


线程安全

线程安全 指在并发环境下多线程中间共享的数据能够被正确的处理,确保进行的操作符合预期(符合单线程下执行的结果)

可以将共享数据按照安全程度的强弱顺序分类:

(1) 不可变:不可变的对象一定是线程安全的,不可变的类型有:

  • final 修饰的基本数据类型;

  • String;

  • 枚举类型;

  • Number部分子类,(Innege、Long等包装类型,BigInteger等大数类型,而 AtomicInteger 等原子类是可变的)

  • 使用 Collections.unmodifiableXxx() 方法获取的不可变集合

通过禁止写操作来保证数据的一致性

(2) 绝对线程安全:无论在单线程还是多线程环境下运行,调用者都不需要任何额外的同步操作;

内部已经实现了线程同步的操作,无论怎么调用都是线程安全的

(3) 相对线程安全:需要保证对这个对象单独的操作是线程安全的,在调用时无需额外的保障措施;而对于特定顺序的连续调用,可能需要额外的同步操作来保证调用的正确性;

  • 比如 Vector 单独插入是线程安全的,但是一边插入一边删除是不安全的,就需要使用同步手段保证线程安全;

单独操作已经实现了线程安全,但是某些连续调用需要额外的线程同步操作;

(4) 线程兼容:对象本身并不是线程安全的,但通过正确的同步手段来调用对象可以保证对象在并发环境中安全使用;

  • Java Api 大部分的类都是属于线程兼容的,比如 ArrayListHashMap 等;

(5) 线程对立:无论如何采取同步手段都无法保证在多线程环境中安全使用;(很少出现)

Java内存模型

Java 内存模型:

  • 所有变量存储在主内存中;

  • 每个线程有拥有自己的工作内存,其中保存了主内存中线程所用到的变量的 副本

  • 线程不能直接读写主内存中的变量,所有操作均在工作内存中执行;

image-xnyv.png

线程想要读写主内存中的变量时,需要经过多个操作:

  • read 读取 主内存 中的变量,把一个变量值 从主内存传输到线程的工作内存中,以便随后的 load 操作;

  • load 载入 工作内存 中的变量,将 read 操作得到的主线程变量值放入到工作内存的变量副本中;

  • use工作内存 中的变量传递给执行引擎,每当虚拟机遇到一个需要 使用 变量值的字节码指令时都会执行该操作;

  • assign 将执行引擎中接收到的值 赋值 工作内存 中的变量,每当虚拟机遇到一个给变量赋值的字节码指令都会执行该操作;

  • store工作内存 中的一个变量值传递给 主内存 中,以便随后的 write 操作;

  • write工作内存 中传递过来的变量值写入主内存 中的变量

线程不安全

在并发环境下没有处理(或者没有正确处理)线程之间共享的变量,导致最终的结果不符合预期,为线程不安全;

结合上面的模型,当处于并发环境中,线程每次获取的变量不一定是最新的,因此写入变量时可能导致某些先前的操作结果丢失;

一个线程不安全的例子:

public class Main {
    static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            for (int i = 0; i < 100000; i ++) count++;
            System.out.println("increase finished");
        }).start();
        new Thread(() -> {
            for (int i = 0; i < 100000; i ++) count--;
            System.out.println("decrease finished");
        }).start();

        Thread.sleep(1000);
        System.out.println(count);
    }
}

上面例子的输出结果不唯一,但是最终输出的 count 不会为0;

  • 因为线程A可能在线程B写入新值到 count 前读取到旧值,然后线程B写入后线程A再写入,就会出现线程B的计算结果丢失;

  • 这就是线程不安全;

该图中两个线程交错运行实际上是,线程A在执行某条指令时被操作系统中断,然后线程B被调度执行,此时访问的值仍然是线程A未修改的值;

线程同步

在并发环境下,要保证预期结果出现,需要保证一组指令以 原子方式 执行,即某一个线程执行时,其他线程必须等待;

原子方式指,一系列的操作视作为一个操作,要么全部执行成功,要么全部执行不成功,而不会出现部分成功的情况;

通过加锁和解锁的操作,保证指令总是在一个线程执行期间,不会有其他线程会进入该指令区间;

  • 即使被操作系统被中断,其他线程也因为无法获得锁导致无法进入此指令区间;

  • 只有执行线程将锁释放后,其他线程才有机会获得锁并执行;

加锁和解锁之间的代码块称之为 临界区,任何时候临界最多只有一个线程能执行;

在上面的例子中,就需要保证读写按照下面的顺序执行:

image-baeq.png

  • 可以使用 AtomicInteger 等采用 CAS 原子操作的原子类实现

    public class Main {
    
        public static void main(String[] args) throws InterruptedException {
            AtomicInteger count = new AtomicInteger();
            new Thread(() -> {
                for (int i = 0; i < 100000; i ++) count.getAndIncrement();
                System.out.println("increase finished");
            }).start();
            new Thread(() -> {
                for (int i = 0; i < 100000; i ++) count.getAndDecrement();
                System.out.println("decrease finished");
            }).start();
    
            Thread.sleep(1000);
            System.out.println(count.get());
        }
    }
    
  • 可以使用 Java 语言提供的 synchronized 代码同步块来处理线程的竞争问题:

    public class Main {
        static int count = 0;
        final static Object lock = new Object();
        public static void main(String[] args) throws InterruptedException {
    
            new Thread(() -> {
                for (int i = 0; i < 100000; i ++) {
                    synchronized (lock) {
                        count++;
                    }
                }
                System.out.println("increase finished");
            }).start();
            new Thread(() -> {
                for (int i = 0; i < 100000; i ++) {
                    synchronized (lock) {
                        count--;
                    }
                };
                System.out.println("decrease finished");
            }).start();
    
            Thread.sleep(1000);
            System.out.println(count);
        }
    }

volatile关键字

线程从主内存拿到了变量数据后,会进行缓存,在仅执行读操作时,只会读取工作内存的副本;

当别的线程修改该变量后,线程并不会拿到更新后的值;

volatile 关键字修饰的变量能够保证该变量的数据可见性:

  • volatile 修饰的变量时,JMM会把本地内存中值刷新到主内存

  • volatile 修饰的变量时,JMM会设置本地内存无效,每次使用该变量都需要从主内存中读取;

volatile 关键字修饰的变量进行操作时,Jvm 需要满足以下规则:

  1. 线程对变量的 read、load、use 动作必须连续一起出现

    1. 规定前一个动作为 load 时才能执行 use,后一个动作为 use 时才能执行 load

    2. 保证了线程每次使用变量时都需要从主内存中拿到最新的值,保证了其他线程修改的变量当前线程能够看到

  2. 线程对变量的 assign、store、write 动作必须连续一起出现

    1. 规定前一个动作是 assign 时才能执行 store,后一个动作是 store 时才能执行 assign

    2. 保证了线程每次修改变量后都会立即同步回主内存,保证了当前线程修改的变量其他线程能看到;

  3. 保证 volatile 修饰的变量不会被执行重排序优化,代码的执行顺序与指令的执行顺序相同;

    1. 对一个volatile变量的写操作在其之后的任何读操作之前完成(在程序中所有线程可见)。

    2. 对一个volatile变量的读操作在其之前的任何写操作之后完成(在程序中所有线程可见)。

什么时候能使用 volatile 关键字替代锁?

  • 对变量的写操作不依赖于当前值;(不能用于线程安全计数器 A++)

  • 该变量没有包含在具有其他变量的不变式中(比如 A < B 或者 A == B等);

适用场景

状态标志

用来表示发生过某个事件,保证其他线程能够及时得知该事件的发生;
(适用于仅有一种状态转换,可以扩展为来回转换,但是只有在转换周期不被察觉的情况下才能扩展)

如果没有用 volalite 修饰,会导致另一个线程读取该变量后,没有及时更新为修改后的变量;

在下面的例子中去掉 volalite 关键字后,线程 thread 仍然运行;

class TestVolatile1 implements  Runnable {
	// 标识需要停止
    private volatile boolean shutdown = false;

    public void shutdown() {
        this.shutdown = true;
    }

    public void run() {
        System.out.println("Start");
        while (!shutdown) {
            // actions
        }
        System.out.println("Shutdown");
    }


    public static void main(String[] args) throws InterruptedException {
        TestVolatile1 test = new TestVolatile1();
		// 一个线程启动来调用 shutdown
        new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            test.shutdown();
        }).start();
		// 另一个线程来执行该任务
        Thread thread = new Thread(test);
        thread.start();
        thread.join();
    }
}

禁止指令重排

双重检查锁实现如果不使用 volatile 修饰,可能导致某个线程拿到的实例为未初始化的,即未调用构造函数

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

在上面的代码中,首先要了解 Jvm 的指令重排:当变量之间不存在依赖关系时,可能会进行指令重排

instance = new Singleton() 可以分解为:

  1. memory = allocate() 申请内存空间;

  2. Singleton(memory) 调用构造函数初始化对象;

  3. instance = memory 赋值引用;

因为第二步和第三步不存在依赖关系,所以可能会发生执行重排;

在并发环境下可能出现下面的情况:

时间

线程A

线程B

t1

A1:分配对象内存空间

t2

A3:设置instance指向内存空间

t3

B1:判断instance是否为空

t4

B2:由于instance不为null,线程B将访问instance引用的对象

t5

A2:初始化对象

t6

A4:访问instance引用的对象


此时线程B获取到的实例是一个尚未调用构造方法初始化的实例;

因此需要使用 volatile 修饰变量,禁止指令重排,只有初始化好对象后才赋值给引用;

独立观察

independent observation,定期“发布”观察结果供程序内部使用,就是更新数据能够让别的线程及时获取到该更新的值,但是需要保证写操作是线程安全的;

volatile bean

JavaBean中所有数据成员都用 volatile 修饰,同时 getter 和 setter 方法都必须不添加任何约束

class VolatileBean {
    
    private volatile int counter;
    
    private volatile String desc;

    public int getCounter() {
        return counter;
    }

    public void setCounter(int counter) {
        this.counter = counter;
    }

    public String getDesc() {
        return desc;
    }

    public void setDesc(String desc) {
        this.desc = desc;
    }
}

开销较低的“读-写锁”策略

如果读操作远远大于写操作,可以使用内部锁和 volatile 变量来减少开销;

  • 使用锁保证写操作的原子性;

  • 使用 volatile 修饰变量允许多个线程执行读操作;

class Counter {
    
    private volatile int count = 0;
    
    public int getCount() {
        return count;
    }
    
    public synchronized int getAndIncrement() {
        return count ++;
    }
}

synchronized关键字

synchronized 关键字中的作用域为 临界区,能保证同一时刻只有一个线程能够进入临界区,同时还保证了共享变量的内存可见性;

  • 通过 lock 和 unlock 操作保证原子性;

  • 通过对一个变量 unlock 前,把变量同步回主存内,保证了可见性;

  • 通过一个变量在同一个时刻只运行最多一个线程对其进行 lock 操作,保证了有序性

Java中每个对象都可以作为锁,是 synchronized 实现线程同步的基础;

volatile 关键字实现了 synchronized 关键字的一部分功能:可见性和有序性,但没有原子性;

synchronized 关键字需要指定某个对象作为锁;

  • 指定类时(*.class)或者修饰静态方法时,对这个类的所有对象生效,即使是不同对象调用同一个代码块也会进行同步(换句话说,所有对象共用一把锁);

  • 指定对象时,仅对该对象生效,同一时刻最多只有一个线程能获取该对象的锁;

synchrionized 实际上是非公平的,新来的线程可能立即获得锁,而等待很久的线程可能再次等待,可能会导致饥饿现象,有利于提高性能(不用记录等待时间)

修饰方法:临界区范围为整个方法

class SynchronizedTest {

    synchronized void run(String value) {
        System.out.println("running: " + value);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            //
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedTest test = new SynchronizedTest();
        Thread a = new Thread(() -> {
            test.run("Thread A");
        });

        Thread b = new Thread(() -> {
            test.run("Thread B");
        });
        a.start();
        b.start();
        a.join();
        b.join();
    }
}

// 运行结果:两句输出不会同时输出,而是会相隔1s

实际上相当于 synchronized(xxx.this) ,隐式的将当前对象作为锁;

通过下面的例子来说明

class SynchronizedTest {

    synchronized void run(String value) {
        System.out.println("running: " + value);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            //
        }
    }

    synchronized void run() {
        System.out.println("running run");
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedTest test = new SynchronizedTest();
        Thread a = new Thread(() -> {
            test.run("Thread A");
        });

        Thread b = new Thread(() -> {
            test.run();
        });
        a.start();
        b.start();
        a.join();
        b.join();
    }
}

两个线程同时访问不同的方法,按理来说应该是能够同时发生的,但是运行结果并不是同时发生,期间相隔了1s,说明两个线程需要排队进入临界区;

这也印证了隐式使用了方法所属对象作为锁,两个线程都需要拿到这个对象才能访问对应的方法

修饰代码块:临界区范围为 synchronized 关键字的 {} 内;而临界区外的代码不受同步影响;

class SynchronizedTest {

    void run(String value) {
        System.out.println("starting: " + value);

        synchronized (this){
            System.out.println("running: " + value);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                //
            }
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        SynchronizedTest test = new SynchronizedTest();
        Thread a = new Thread(() -> {
            test.run("Thread A");
        });

        Thread b = new Thread(() -> {
            test.run("Thread B");
        });
        a.start();
        b.start();
        a.join();
        b.join();
    }
}
// 没有用 synchronzied 修饰的代码块,线程无需等待获取锁,因此 starting: xxx 是同时输出的

原理

每个对象都有一个锁计数器,synchronized 关键字在编译为字节码时对应指令 monitorentermonitorexit,会让对象的锁计数器加1或减1;

每个对象在同一时间都只能和一个 monitor (锁)关联,而一个 monitor 在同一个时间内只能被一个线程获得;

当一个线程尝试获得与对应对象相关联的 monitor 的所有权时,monitorenter 指令会发生下面三种情况之一:

  • 如果 monitor 计数器为0,意味没有线程获得该锁,那么该线程会立即获得并把锁计数器+1,别的线程要获取该锁需要等待锁计数器为0;

  • 如果线程已经获取到这个锁的所有权,又重入了该锁(重新进入临界区),那么锁计数器会累加;

  • 如果锁已经被别的线程获取,等待所释放;

monitorexit 指令负责给锁计数器-1,当锁计数器变为0时,表示当前线程不再拥有该 monitor 的所有权,即释放锁;

image-viey.png

任意线程对对象的访问,首先要获得该对象相关联的 monitor;

如果获取失败则进入同步堵塞状态(BLOCKED),当对象的 monitor 所有权释放时,在同步队列中的线程就有机会获得该 monitor 的所有权;

原子性

对象锁保证同一时刻最多只有一个线程可以获得,保证了原子性;

可重入性

可重入锁:可以被同一个线程重复获取而不会导致死锁的锁,不会因为之前获取过未释放而堵塞;

对于 synchronized 来说,重入只是给锁计数器+1;

在同一锁程中,当线程获得对象锁后,只是给 monitor 计数器+1,每次重复获取都是给计数器+1,线程无需再次获取同一把锁;

可见性

happens-before规则,也称监视器锁规则:

对同一个 monitor 的解锁,happens-before(发生前于)对 monitor 的加锁;

而对对象的解锁,会让本地变量刷新到主存中,因此当对象解锁时,临界区执行的结果会立即刷新到主存;

下一个线程获取锁的时机一定在解锁后,也就是执行临界区代码的时机在先前线程执行后,那么线程就能读取到刷新到主存的值,这就是可见性

缺陷

  1. 效率低:只有代码执行完毕或者出现异常时才会释放锁,其他线程等待进入临界区时无法超时中断,也无法中断一个正在使用锁的线程;

  2. 不够灵活:加锁和释放锁的时机单一;

  3. 无法得知是否成功获得锁

高并发环境下,多线程竞争一个锁时,其他未获得锁的线程只能不停的尝试获得锁,会导致性能下降;

notify & notifyAll & wait

Object 提供了 notifynotifyAllwait 的 native 方法来进行线程同步操作;

  • wait:使当前持有锁的线程进入等待状态,锁会被释放;

    • 如果是无参的 wait 方法,除非手动调用 notify 或者 notifyAll 方法,否则会一直处于等待状态;

    • 重载的 wait 方法支持传入超时时间,当等待时间超过超时时间还未被唤醒,则会自动唤醒;

  • notify :唤醒一个等待获取该锁的线程,如果有多个则是随机唤醒一个;

  • notifyAll :唤醒所有等待获取该锁的线程;

注意这些方法都是在作为锁的对象上调用;


锁的概念

image-bake.png

悲观锁&乐观锁

广义上的概念,从线程同步的不同角度出发;

悲观锁:认为在使用数据的时候,一定会有别的线程来修改数据,因此在获取数据时先加锁,确保数据不会被其他线程修改;

  • synchronized 关键字的 Lock 的实现类都是悲观锁

乐观锁:认为在使用数据时不会有别的线程修改数据,所以不会添加锁,而是在更新数据时判断之前是否有线程更新了该数据;

  • 如果该数据没有被更新,那么当前线程就把自己修改的数据成功写入;

  • 如果数据已经被其他线程更新,那么根据不同的实现方式执行不同的操作;(报错或者自动重试)

  • Java中通过无锁编程来实现,常用的是 CAS 算法

AtomaicInteger 的递增操作就是通过 CAS 自旋实现

悲观锁适合写操作多的场景:先加锁可以保证写操作时数据正确,避免因为数据出错重试的开销;

乐观锁适合读操作多的场景:不加锁可以使其读操作的性能大幅提升,避免加锁带来的开销

CAS

指的是 Compare-And-Swap 硬件指令(比较并交换),该指令是 原子性操作,还是非堵塞的,且硬件保证其可靠性

需要3个操作数,分别是内存地址V、预期旧值A和新值B,当V的值等于A时,才将V的值更新为B;

为什么CAS可以保证线程安全?

对于 x = y 这种变量赋值语句,实际上会被分为两步走:

  1. 读取 y 变量的值

  2. 将读到的值赋值给 x 变量

此时这个操作并不是 原子性 的,那么就不是线程安全的;

而 CAS 是原子性操作,比较和赋值是同一次操作进行,因此能保证线程安全;

线程A先比较当前值是否有线程更新,如果没有则将其更新;

而线程B在线程A更新后比较,发现有线程更新,则比较失败,然后自旋一定次数后退出;

CAS自旋

当线程进行 CAS 更新失败时,通常会循环重试一定次数,这时 循环重试 的过程被称之为 CAS 自旋;

线程循环重试 CAS,继续尝试更新,而不是更新失败就退出;

但是如果自旋时间过长,会导致CPU资源的浪费;

AtomaicInteger 内部就是使用 Unsafe 类提供的 CAS 操作(通过 native 层调用汇编指令)

  • volatile int valueAtomaicInteger的值,使用 volatile 关键字修饰,保证其他线程更新时当前线程能及时看到,也就是说每次读都肯定是最新的值;

  • long valueOffsetvalue 在内存中的偏移量,CAS 相关方法需要该值

以其 getAndIncrement 方法为例:

// AtomicInteger#getAndIncrement
public final int getAndIncrement() {
	// U 为 Unsafe 类的实例
    return U.getAndAddInt(this, VALUE, 1);
}

// Unsafe#getAndAddInt
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
		// 拿到主存中最新的值 v
        v = this.getIntVolatile(o, offset);
    } while(!this.weakCompareAndSetInt(o, offset, v, v + delta)); // 进行 CAS 操作,更新失败时返回false,进行自旋
    return v;
}

// Unsafe#weakCOmpareAndSetInt
public final boolean weakCompareAndSetInt(Object o, long offset, int expected, int x) {
    return this.compareAndSetInt(o, offset, expected, x);
}

// Unsafe#compareAndSetInt
public final native boolean compareAndSetInt(Object var1, long var2, int var4, int var5);

ABA问题

当某个A值被一个线程更新为B值后,又被另一个线程更新为A值,此时 CAS 对 A 值已经被更新时无法感知的;

对于基本数据类型并没有什么影响,但是对于引用类型可能存在问题,一般解决方法是通过 版本号 给当前值打上标识符;

因此 JUC 提供了 AtomicStampedReference 来解决这个问题,通过给每个引用加上当前的时间戳,进行 CAS 操作时会比较时间戳和引用的值来判断是否更新;


自旋锁 & 适应性自旋锁

堵塞或唤醒一个线程需要操作系统切换内核态来完成,这种切换开销大、耗费处理器时间,有些时候切换状态所消耗的时间甚至比同步代码块的执行时间还要长;

如果物理机上有多个处理器,能够让两个或以上的线程同时 并行 执行,当遇到同步块时,先拿到锁的线程需要释放锁才能让其他线程有机会获得锁;

  • 而其他线程可能因为锁被持有而进入堵塞状态(BLOCKED),此时如果放弃CPU的执行时间,就会产生切换开销;

  • 可以让其他线程 “等待“ 一下,也就是进行 自旋,如果在自旋完成后锁被释放,那么线程就可以直接拿到锁,而避免切换线程产生的开销;

自旋虽然避免了线程切换的开销,但是要占用CPU处理器的时间:

  • 如果占用时间短,那么自旋就是有效果;

  • 如果占用时间过长,那么就是浪费CPU资源;

因此自旋需要有限定的次数,如果自旋超过了限定次数,那么就需要挂起线程了;

Java 中可以通过 -XX:PreBlockSpin 来更改,默认是10次

公平锁 & 非公平锁

公平锁:指多个线程按照其申请锁的顺序来获取锁,等待线程直接进入队列中排队,只有队列的第一个线程才能获取锁;

  • 优点:等待锁的线程不会被饿死;

  • 缺点:整体吞吐效率相较于非公平锁要低,等待队列中除了第一个线程以外的所有线程都会被堵塞,CPU唤醒堵塞线程的开销比非公平锁大;

非公平锁:多个线程尝试获取锁时,如果锁刚好可用,那么该线程可以直接获取到锁而无需堵塞,否则需要进入等待队列等待;()

  • 优点:可以减少唤醒线程的开销,整体的吞吐效率高(因为线程有概率不堵塞直接获得锁,省去了CPU唤醒线程的开销)

  • 缺点:处于等待队列中的线程可能会被饿死,或者需要等待很久才会获得锁;

可重入锁 & 非可重入锁

可重入锁,也叫做递归锁,持有A锁的线程在重新获取A锁时不需要申请直接获取,不会因为A锁没有释放而导致死锁;

  • synchronizedReentrantLock 都是可重入锁;

synchronized 编译为字节码指令为 monitorentermonitorexit,用于增减锁计数器,再次获取锁时只需自增计数器即可;

ReentrantLocal 实现:

  • volatile 修饰的 state 变量指示当前锁是否被持有,同时也作为持有锁的计数器

  • Sync#setExclusiveOwnerThread 记录获得锁的线程

// ReentrantLocal.Sync#tryLock
final boolean tryLock() {
    Thread current = Thread.currentThread();
    int c = this.getState();
	// c 为 0 时表示锁还没有被占用
    if (c == 0) {
		// 原子操作更新
        if (this.compareAndSetState(0, 1)) {
            // 记录当前线程持有锁
			this.setExclusiveOwnerThread(current);
            return true;
        }
	// 判断线程是否为持有锁的线程,就是可重入的关键
    } else if (this.getExclusiveOwnerThread() == current) {
        // 这里无需同步操作,因为判断语句保证了同一时间最多只有一个线程进入到这里执行
		++c;
		
        if (c < 0) {
            throw new Error("Maximum lock count exceeded");
        }
        this.setState(c);
        return true;
    }
    return false;
}

非可重入锁,与可重入锁不同,持有A锁的线程在重新获取A锁时,需要先释放持有的A锁才能获取;

ThreadPoolExecutor 中的 Worker 中的 tryAcquire()tryRelease() 方法都是实现了非可重入锁:

// ThreadPoolExecutor.Worker#tryAcquire
protected boolean tryAcquire(int unused) {
    if (this.compareAndSetState(0, 1)) {
        this.setExclusiveOwnerThread(Thread.currentThread());
        return true;
    } else {
        return false;
    }
}

// ThreadPoolExecutor.Worker#tryRelease
protected boolean tryRelease(int unused) {
    this.setExclusiveOwnerThread((Thread)null);
    this.setState(0);
    return true;
}

通过 CAS 原子性操作保证每个时刻只有一个线程能够修改 state 参数,保证能够进入判断语句块并设置当前的 Thread;

如果再次调用 tryAcquire() 时,因为 state 已经被修改为持有状态,那么就无法再获得锁;


独享锁(排它锁、互斥锁) & 共享锁 & 读写锁

独享锁/排它锁/互斥锁:锁一次只能被一个线程锁持有,如果线程A已经给数据D加上排它锁后,其他线程就不能给数据D加上其他类型的锁;

  • 获得排它锁的线程既能读数据也能修改数据

  • Java中 synchronized 关键字和 JUC 的 Loc 实现类就是互斥锁

共享锁:该锁可以被多个线程持有,如果线程A对数据D加上共享锁后,其他线程只能对数据D加上共享锁,而不能加排它锁;

  • 获得共享锁的线程只能读数据,而不能修改数据;

读写锁

  • 执行读操作时持有共享锁,允许多个线程并发执行读操作,同时该读操作不会影响数据的准确性,持有共享锁时不允许执行写操作;

  • 执行写操作时持有排他锁,允许只有一个线程执行写操作;

ReentrantReadWriteLock 其内部分别实现了 ReadLock 读锁和 WriteLock 写锁,核心是通过 Sync 来实现加锁和释放锁;

而内部类 Sync 是 AQS 的一个子类


无锁 & 偏向锁 & 轻量级锁 & 重量级锁

这四种锁指的是针对 synchronized 同步锁的状态,为 JDK1.6引入的优化方式;

这四种状态会随着线程的竞争情况逐渐升级(只能升级但是不能降级),为了提高获取锁和释放锁的效率;

升级顺序:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 (该过程不可逆)

无锁(No Lock):没有任何线程竞争资源,不用加锁;,

偏向锁(Biased Locking):如果一个线程获得了锁,那么锁就会偏向该线程,之后该线程再请求锁时,无需进行任何同步操作

  • 当只有一个线程访问同步块时,偏向锁能提高性能;

  • 如果存在其他线程访问同步块,尝试获取偏向锁时,偏向锁会升级为轻量级锁(产生一定开销)

在对象的对象头 Mark Word 中记录了持有偏向锁的线程ID,当该线程再次进入同步块时,只需要比较对象头的线程ID即可,而无需进行加锁操作;

轻量级锁(Lightweight Locking):多个线程短时间竞争时,没有获取到锁的线程通过 自旋 CAS 操作来避免进入堵塞状态

  • 多线程短时间竞争指,多个线程占用锁的时间较短,未获取到锁的线程只需要通过自旋等待锁释放,就能再次尝试获得锁;

  • 可以避免线程堵塞和唤醒的开销;

  • 如果自旋次数过多,会消耗大量CPU资源,此时会将锁升级为重量级锁,也就是线程进入堵塞状态

轻量级锁的实现依赖于对象头的Mark Word和线程栈中的锁记录(Lock Record)。

当一个线程尝试获取轻量级锁时,会在自己的栈帧中创建一个锁记录,并通过CAS操作将对象头中的Mark Word替换为指向锁记录的指针。

如果CAS操作成功,表示获取了锁;否则,进入自旋。

重量级锁(Heavyweight Locking):多线程 长时间 竞争时,使用操作系统的互斥量(mutex)来实现锁机制,此时其他未获取到的锁的线程会进入堵塞状态,等待操作系统唤醒;

  • 多线程长时间竞争指,线程占用锁的时间较长,若让线程自旋等待会浪费CPU的资源,因此堵塞线程;

在重量级锁状态下,锁的所有者线程会被挂起,直到锁被其他线程释放。

这种锁状态的管理和调度由操作系统完成,JVM通过操作系统的互斥量来实现锁的获取和释放。


JUC使用

虚假唤醒

虚假唤醒(Spurious Wakeup)是指线程在等待条件变量(如使用ConditionObjectwait方法)时,即使没有接收到明确的通知(如signalnotify),也会从等待状态中返回。

  • 操作系统的调度机制:操作系统在调度线程时,可能会出于优化或其他考虑,唤醒一些等待中的线程。

  • 硬件中断:某些硬件中断也可能导致等待的线程被唤醒。

  • JVM实现细节:不同的JVM实现可能在处理线程等待和唤醒时有一些细微的差异,从而引发虚假唤醒。

为了预防虚假唤醒,应该始终在循环中调用等待方法,而不是在条件语句中调用。这确保了线程在被唤醒后,会重新检查条件,只有在条件满足时才会继续执行。


ReentrantLock

可重入锁,可设置是否为公平锁,可以判断锁的状态;

lock() 为获取该锁,如果该锁已经有线程持有,那么当前线程会进入堵塞队列AQS

tryLock() 为尝试获取该锁,如果能够获取,则返回 true,否则返回 true

  • tryLock(long timeout, TimeUnit unit) 会在指定时间内尝试获取该锁,如果在这段时间内获取到锁则返回 true,否则返回 false

unlock() 为释放该锁

isLocked() 可以获取当前锁的状态,是否被持有;

原理

实际上 ReentrantLock 的具体实现交由内部的 Sync 实现,而 Sync 有两个实现子类:NonFairSync 非公平锁和 FairSync 公平锁,

调用 lock() 方法时,实际上是调用 Sync#lock()

  • 先调用 Sync#initialTryLock() 尝试能否直接获得锁,根据具体实现类有不同的表现

    • 对于公平锁:如果锁没有线程持有,且同步队列中没有线程,那么将锁给当前线程;

    • 对于非公平锁:如果锁没有线程持有,不管同步队列中是否有线程,只要能通过 CAS 操作将锁状态更新成功,那么就将锁给当前线程;

  • 如果上面的方法返回 false,则说明不能直接获取锁,则调用 Sync#acquire(int)

    • 内部调用 AbstractQueuedSynchronizer#tryAcquire() 再次尝试获取锁,如果能够则将锁给当前线程并返回true

    • 如果还是不能获得锁,则调用 AbstractQueuedSynchronizer#acquire(Node node, int arg, boolean shared, boolean interruptible, boolean timed, long time) 执行同步堵塞队列的操作(有点复杂,还没完全明白,大概就是将当前线程作为Node入队)

调用 unlock() 方法时,实际上是调用 Sync#release(int)

  • 先调用 Sync#tryRelease(int) 尝试释放锁:

  • 如果锁能够被成功释放,则调用 AbstractQueuedSynchronizer#signalNext(Node) ,唤醒队列中的第一个堵塞线程 LockSupport.unpark(s.waiter);

  • 如果锁没有释放,则意味当前线程重入该锁多次,则不能唤醒队列中第一个线程;

公平锁和非公平锁的区别主要在 Sync#initialTryLock() 这个方法;


Condition

提供一个线程协调机制,线程等待某种 Condition 满足时可以被唤醒;

需要配合 Lock 对象使用,同一个 Lock 对象可以创建多个 Condition 对象,每个 Condition 对象都拥有一个 AQS 等待队列;

在线程需要等待的位置使用 Condition#await()

在条件满足时调用对应 Condition 的 signal()signalAll() 方法;

和 Object 的 notify/notifyAll/wait 很类似

为了防止线程虚假唤醒,需要在循环中调用 Condition#await()