Skip to content

并发与线程

1. 基本概念

1.1. 进程、线程和协程

  • 进程:程序执行时的一个实例,一个进程至少包含一个线程,同进程里多个线程可共享数据,不同进程之间切换代价大。
  • 线程:CPU 调度的基本单位,线程上下文切换代价比进程小,是进程的一个实体。
  • 协程:是一种用户态的轻量级线程,一个线程可包含多个协程。进程和线程都是同步,协程是异步。

1.2. 并行与并发

  • 并行指多个事件在同一个时刻发生;并发指在某时刻只有一个事件在发生,某个时间段内由于 CPU 交替执行,可以发生多个事件。
  • 并行没有对 CPU 资源的抢占;并发执行的线程需要对 CPU 资源进行抢占。
  • 并行执行的线程之间不存在切换;并发操作系统会根据任务调度系统给线程分配线程的 CPU 执行时间,线程的执行会进行切换。

1.3. 并发编程的问题

并发编程会带来一系列的问题,通常归类为原子性、可见性和有序性问题。

1.3.1. 原子性

原子性指一个操作中 cpu 不能中断,要么不执行,要么执行完成。

常见的操作导致原子性问题比如多个线程对共享变量 i 进行 i++ 操作,结果可能与预期的值不一样,比如以下测试代码结果可能会小于10000。

private int i = 0;
private void increase() {
    i++;
}
@Test
public void accumulation() {
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            for (int j = 0; j < 1000; j++) {
                increase();
            }
        }).start();
    }
    while (Thread.activeCount() > 2) {
        Thread.yield();
    }
    // expected value is 10000
    System.out.println("i actual value: " + i);
}

Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。

1.3.2. 可见性

可见性指多线程访问变量,一个线程修改了变量,其他线程能够立即看到修改值。

可见性问题指当一个线程修改了共享变量的值,其它线程未能能够立即得知这个修改,读取的还是旧值。Java 内存模型对于同步规定变量在修改后要将新值同步回主内存,变量读取前要从主内存刷新变量值以实现可见性的。

三种实现可见性的方式:

  • volatile,一个线程修改了被 volatile 修饰的变量,新值对其他线程来说是立即可见的。(volatile 的可见性例子见Java内存模型-volatile

  • synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。

  • final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。

1.3.3. 有序性

有序性即按照代码顺序执行。

有序性问题是指在本线程内观察,所有操作都是有序的,但在其他线程观察下,所有操作不一定是按顺序执行的,无序是因为发生了指令重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

有序性问题的一个例子,单例模式的双检锁实现(见Java内存模型-volatile)。

volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。

也可以通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。

1.4. 先行发生原则(happens-before)

上面提到了可以用 volatile 和 synchronized 来保证有序性。除此之外,JVM 还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成。

单一线程原则 Single Thread Rule
在一个线程内,在程序前面的操作先行发生于后面的操作。

管程锁定规则 Monitor Lock Rule
一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

volatile 变量规则 Volatile Variable Rule
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

线程启动规则 Thread Start Rule
Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。

线程加入规则 Thread Join Rule
Thread 对象的结束先行发生于 join() 方法返回。

线程中断规则 Thread Interruption Rule
对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。

对象终结规则 Finalizer Rule
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

传递性 Transitivity
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

1.5. qps 和 tps 区别

1.5.1. 定义

QPS:Queries Per Second,意思是“每秒查询率”,是一台服务器每秒能够响应的查询次数,是对一个特定的查询服务器(比如是读写分离的架构,就是读的服务器)在规定时间内所处理流量多少的衡量标准。

TPS:TransactionsPerSecond,意思是每秒事务数,一个事务是指一个客户机向服务器发送请求然后服务器做出反应的过程。客户机在发送请求时开始计时,收到服务器响应后结束计时,以此来计算使用的时间和完成的事务个数。

每个事务包括了如下3个过程:
- 用户请求服务器
- 服务器自己的内部处理(包含应用服务器、数据库服务器等)
- 服务器返回给用户
如果每秒能够完成N个这三个过程,tps就是N;

1.5.2. 解释

qps,如果是对一个页面请求一次,形成一个 tps,但一次页面请求,可能产生多次对服务器的请求(页面上有很多 html 资源,比如图片等),服务器对这些请求,就可计入“qps”之中;

但是,如今的项目基本上都是前后端分离的,性能也分为前端性能和后端性能,通常默认是后端性能,即服务端性能,也就是对服务端接口做压测。

如果是对一个接口(单场景)压测,且这个接口内部不会再去请求其它接口,那么tps=qps,否则,tps≠qps

如果是对多个接口(混合场景)压测,不加事务控制器,jmeter 会统计每个接口的 tps,而混合场景是要测试这个场景的 tps,显然这样得不到混合场景的 tps,所以,要加了事物控制器,结果才是整个场景的 tps。

1.5.3. 计算方式

并发用户数:指的是现实系统中操作系统业务的用户,一般测试指的是虚拟用户(Vu),并发用户和注册用户数、在线用户数是有很大区别的,并发用户数一定会对服务器产生压力的,而在线用户只是”挂”在系统上,对服务器不会产生压力,注册用户数一般指的是数据中存在的用户数。

QPS(TPS)= 并发数/平均响应时间 
或者 
并发数 = QPS*平均响应时间
- 系统压力小的时候,并发数和 QPS 呈正相关,比如此时响应时间都小于1秒; - 当系统压力大的时候,比如响应时间远大于1秒,QPS 可能会趋向最大值或反而会下降。

1.6. 多线程什么情况下使用

  • 多数据并行处理
  • 异步请求网络(例如发短信发邮件通知等等,不影响主业务逻辑)
  • IO密集型业务(文件读写,数据库操作)

2. 线程

2.1. 线程的生命周期

线程的生命周期包含5个阶段,包括:新建、就绪、运行、阻塞、销毁。

img

  • 新建:就是刚使用 new 方法,new 出来的线程;
  • 就绪:就是调用的线程的 start() 方法后,这时候线程处于等待 CPU 分配资源阶段,谁先抢的 CPU 资源,谁开始执行;
  • 运行:当就绪的线程被调度并获得 CPU 资源时,便进入运行状态,run 方法定义了线程的操作和功能;
  • 阻塞:在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态,比如 sleep()、wait() 之后线程就处于了阻塞状态,这个时候需要其他机制将处于阻塞状态的线程唤醒,比如调用 notify 或者 notifyAll() 方法。唤醒的线程不会立刻执行 run 方法,它们要再次等待 CPU 分配资源进入运行状态;
    继续细分阻塞休眠有三个状态 WAITING、TIME_WAITING 和 BLOCKED。sleep() 或 wait(long timeout) 执行之后进入 TIME_WAITING 状态,wait() 或 join() 执行后进入 WAITING 状态。
  • 销毁:如果线程正常执行完毕后或线程被提前强制性地终止或出现异常导致结束,那么线程就要被销毁,释放资源。

2.2. 创建线程

  • 继承 Thread 类,重写 run() 方法
  • 实现 Runnable 接口,重写 run() 方法
  • 实现 Callable 接口,使用 FutureTask 类创建线程或使用 Executors 线程池提交执行

3. 线程关键字和方法函数相关

3.1. wait notify 机制

  • 调用 obj 的 wait(), notify() 方法前,必须获得 obj 锁,即必须在同步代码块中使用(例如 synchronized);
  • 调用 obj.wait() 后,当前线程就释放了 obj 的锁;
  • 当 obj.wait() 方法返回后,线程需要再次获得 obj 锁,才能继续执行;
  • obj.notify() 只能唤醒一个进行 obj.wait() 后的线程(具体哪一个由 JVM 决定);
  • obj.notifyAll() 则能全部唤醒,但是要继续执行 obj.wait() 的下一条语句,必须获得 obj 锁,具体哪个线程获得锁由优先级和系统调度决定。

3.2. sleep 和 wait 区别

  • sleep() 是 Thread 类的静态本地方法;wait() 是 Object 类的成员本地方法;
  • sleep() 方法可以在任何地方使用;wait() 方法则只能在同步方法或同步代码块中使用,否则抛出 java.lang.IllegalMonitorStateException 异常
  • sleep() 会休眠当前线程指定时间,释放 CPU 资源,不释放对象锁,休眠时间到自动苏醒继续执行;wait() 方法放弃持有的对象锁,进入等待队列,当该对象被调用 notify() / notifyAll() 方法后才有机会竞争获取对象锁,进入运行状态
  • JDK1.8 sleep() wait() 均需要捕获 InterruptedException 异常

3.3. volatile 和 synchronized 区别

  • synchronized 表示只有一个线程可以获取作用对象的锁,执行代码会阻塞其他线程;
  • volatile 表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。作用是保证多线程环境下变量的可见性,并且禁止指令重排序。

3.4. synchronized 和 Lock

  • 实现层面不一样。synchronized 是 Java 关键字,JVM 层面实现加锁和释放锁;Lock 是一个接口,在代码层面实现加锁和释放锁;
  • 是否自动释放锁。synchronized 在线程代码执行完或出现异常时自动释放锁;Lock 不会自动释放锁,一般需要在 finally {} 代码块显式地中释放锁;
  • 是否一直等待。synchronized 会导致线程如果拿不到锁一直等待;Lock 可以使用 tryLock() 方法尝试获取锁或者设置获取锁超时时间;
  • 获取锁成功是否可知。synchronized 无法得知是否获取锁成功;Lock 可以通过 tryLock() 获得加锁是否成功;
  • 功能复杂性。synchronized 加锁可重入、不可中断、非公平;Lock 可重入、可判断、可公平和不公平、细分读写锁提高效率。

3.5. yield

  • 暂停当前正在执行的线程对象,不会释放资源锁。
  • 和 sleep 不同的是 yield 方法并不会让线程进入阻塞状态,而是让线程重回就绪状态,它只需要等待重新获取 CPU 执行时间,所以执行 yield() 的线程有可能在进入到可执行状态后马上又被执行。还有一点和 sleep 不同的是 yield 方法只能使同优先级或更高优先级的线程有执行的机会。

3.6. join

等待调用 join 方法的线程结束,再继续执行。

3.7. Lock 接口六个方法

  • void lock() // 获取锁
  • void lockInterruptibly() // 如果当前线程未被中断,则获取锁,可以响应中断(调用 interrupt()方法)
  • Condition newCondition() // 返回绑定到此 Lock 实例的新 Condition 实例
  • boolean tryLock() // 仅在调用时锁为空闲状态才获取该锁,可以响应中断
  • boolean tryLock(long time, TimeUnit unit) // 如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁
  • void unlock() // 释放锁

3.8. ReentrantLock 可重入锁

构造方法(不带参数 和带参数 true: 公平锁; false: 非公平锁)

3.9. 异步处理框架

Executors 和 Future(Java8 的 CompletableFuture),Rxjava 等

3.10. CAS

Compare and swap 比较交换,CAS 利用 CPU 指令保证操作的原子性。自旋锁,失败不断循环执行直到成功。

3.11. volatile

禁止指令重排序。表示变量在 CPU 的寄存器中是不确定的,必须从主内存中读取。保证多线程环境下变量的可见性。

3.12. ThreadLocal

ThreadLocal 线程局部变量,同一个 ThreadLocal 所包含的对象,在不同的 Thread 中有不同的副本。

使用场景:JDBC 连接池、存储 session。

作用:每个线程需要有自己单独的实例;实例需要在多个方法中共享,但不希望多线程共享。

可能导致的内存泄漏:
ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 没有外部强引用来引用它,那么系统 GC 的时候,这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value,如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value 永远无法回收,造成内存泄漏。

其实,ThreadLocalMap 的设计中已经考虑到这种情况,也加上了一些防护措施:在 ThreadLocal 的 get(),set(),remove() 的时候都会清除线程 ThreadLocalMap 里所有 key 为 null 的 value。

如果希望当前线程的 ThreadLocal 能够被子线程使用,可以使用 InheritableThreadLocal。InheritableThreadLocal 主要用于子线程创建时,需要自动继承父线程的 ThreadLocal 变量,方便必要信息的进一步传递。

private static ThreadLocal<Dog> local = new InheritableThreadLocal<>();
//如果是 newFixedThreadPool(2) 第二个输出不一定都是10
private static ExecutorService pool = Executors.newFixedThreadPool(1);

public static void localTest() {
    local.set(new Dog());
    pool.execute(() -> {
        System.out.println(local.get().age);
    });

    Dog dog = new Dog();
    dog.setAge(20);
    local.set(dog);
    pool.execute(() -> {
        System.out.println(local.get().age);
    });
}

public static class Dog {
    private Integer age = 10;
    public void setAge(Integer age) {
        this.age = age;
    }
}

3.13. 守护线程

当所有非守护线程都执行完毕之后,JVM 才会退出。

以下例子,非守护线程执行完毕后才会输出 JVM exited,设置 thread.setDaemon(true) 后主线程就不等待子线程执行完成而是直接结束。

public static void main(String[] args) {
    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
        System.out.println("JVM exited.");
    }));
    Thread thread = new Thread(() -> {
        for (int i = 0; i < 10; i++) {
            try {
                Thread.sleep(500L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(i);
        }
    });
    //thread.setDaemon(true);
    thread.start();
}

Executors 线程池的默认线程创建工厂 Executors.defaultThreadFactory() 用的是非守护线程,ForkJoin 框架用的是守护线程。

4. 线程池

4.1. JDK 线程池

4.1.1. Executors

  • newCachedThreadPool
    创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。这种类型的线程池特点是:

    • 工作线程的创建数量几乎没有限制(其实也有限制的,数目为 Interger. MAX_VALUE), 这样可灵活的往线程池中添加线程。
    • 如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为1分钟),则该工作线程将自动终止。终止后,如果你又提交了新的任务,则线程池重新创建一个工作线程。
    • 在使用 CachedThreadPool 时,一定要注意控制任务的数量,否则,由于大量线程同时运行,会造成系统瘫痪。
  • newFixedThreadPool
    创建一个指定工作线程数量的线程池。每当提交一个任务就创建一个工作线程,如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中。
    FixedThreadPool 是一个典型且优秀的线程池,它具有线程池提高程序效率和节省创建线程时所耗的开销的优点。但是,在线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源。

  • newSingleThreadExecutor
    创建一个单线程化的 Executor,即只创建唯一的工作者线程来执行任务,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。如果这个线程异常结束,会有另一个取代它,保证顺序执行。单工作线程最大的特点是可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。

  • newScheduleThreadPool
    创建一个定长的线程池,支持定时及周期性任务执行。

4.2. ThreadPoolExecutor 线程池参数

Executors 底层方法,参数需要自定义。

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), defaultHandler);
    }
- corePoolSize:线程池保有的最小线程数; - maximumPoolSize:线程池创建的最大线程数; - keepAliveTime:存活时间; - unit:keepAliveTime 的时间单位; - workQueue:任务队列 - ArrayBlockingQueue 基于数组的有界阻塞队列,按 FIFO 排序。线程数到达最大时执行拒绝策略; - LinkedBlockingQuene 基于链表的无界阻塞队列; - SynchronousQuene 一个不缓存任务的阻塞队列。新任务进来时,不会缓存,而是直接被调度执行该任务; - PriorityBlockingQueue 具有优先级的无界阻塞队列,优先级通过参数 Comparator 实现。 - threadFactory:线程工厂对象,可以自定义如何创建线程,如给线程指定 name; - handler:自定义任务的拒绝策略,JDK 内置提供了4种拒绝策略 - AbortPolicy: 直接抛出异常; - CallerRunsPolicy:在调用者线程中直接执行被拒绝任务的 run 方法,除非线程池已经shutdown,则直接抛弃任务; - DiscardPolicy:直接丢弃任务,什么都不做; - DiscardOldestPolicy:抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列。

4.3. 线程池工作原理

  1. 线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。

  2. 当调用 execute() 方法添加一个任务时,线程池会做如下判断:
    a.如果正在运行的线程数小于 corePoolSize,那么马上创建线程运行这个任务。
    b.如果正在运行的线程数大于或者等于 corePoolSize,那么将这个任务放入队列。
    c.如果这个时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建线程运行这个任务。
    d.如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会抛出异常,告诉调用者“我不能再接受任务了”

  3. 当一个线程完成任务时,它会从队列中取下一个任务来执行。

  4. 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于 corePoolSize 时,那么这个线程会被停用掉,所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。

5. 线程安全

线程安全指多个线程不管以何种方式访问某个类,在主调代码中不需要进行同步操作,都能表现正确行为。

下面是线程安全的4种实现方式。

5.1. 不可变对象

不可变的对象一定是线程安全的,不需要采取任何的线程安全保障措施。只要有一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。

常见的不可变类型: - final 关键字修饰的基本类型 - String 类 - 枚举类型 - Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。

5.2. 互斥同步

使用 synchronized 关键字修饰的语句和 Lock 接口实现的 ReentrantLock。

5.3. 非阻塞同步

互斥同步最主要的问题是线程阻塞所带来的性能问题,因此这种同步也成为阻塞同步。非阻塞算法有很多种,其中,CAS 是常见的无锁算法。

CAS

乐观锁需要操作冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能依靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(Compare and Swap,CAS)。

AtomicInteger

J.U.C 包里面的整数原子类,AtomicInteger 的方法调用了 Unsafe 类的 CAS 操作。

ABA 问题

如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。

J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。

5.4. 无同步方案

要保证线程安全,并不是一定就要进行同步,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

5.4.1. 栈封闭

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

public class StackClosedExample {
    public void add100() {
        int cnt = 0;
        for (int i = 0; i < 100; i++) {
            cnt++;
        }
        System.out.println(cnt);
    }
}
public static void main(String[] args) {
    StackClosedExample example = new StackClosedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> example.add100());
    executorService.execute(() -> example.add100());
    executorService.shutdown();
}

输出

100
100

5.4.2. 线程本地存储(Thread Local Storage)

如果一段代码所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据竞争的问题。

可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。

5.4.3. 可重入代码(Reentrant Code)

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源;用到的状态量都由参数中传入;不调用非可重入的方法等。

6. 各种锁类型

相关参考 https://tech.meituan.com/2018/11/15/java-lock.html

6.1. 互斥访问

Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized 关键字,而另一个是 JDK 实现的 ReentrantLock 类。

除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。

6.2. 悲观锁和乐观锁

悲观锁:假设每一次拿数据,都有认为会被修改。synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。

乐观锁:每次去拿数据的时候都认为别人不会修改。可以使用版本号机制和 CAS 算法实现。

6.3. 自旋锁和适应性自旋锁

自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

6.4. 无锁、偏向锁、轻量级锁和重量级锁

无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。

轻量级锁是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

CAS 是轻量级锁自旋的一种实现。

重量级锁升级为重量级锁时,锁标志的状态值变为“10”,此时 Mark Word 中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

6.5. 公平锁和非公平锁

  • 公平锁:队列排队执行,其他线程全部堵塞,CPU 唤醒线程代价大。
  • 非公平锁:CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

6.6. 可重入锁和不可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java 中的 ReentrantLock 和 synchronized 实现都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

当线程尝试获取锁时,可重入锁先尝试获取并更新 status 值,如果 status == 0 表示没有其他线程在执行同步代码,则把 status 置为1,当前线程开始执行。如果 status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行 status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前 status 的值,如果 status != 0 的话会导致其获取锁失败,当前线程阻塞。

释放锁时,可重入锁同样先获取当前 status 的值,在当前线程是持有锁的线程的前提下。如果 status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将 status 置为0,将锁释放。

6.7. 独享锁和共享锁

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程 T 对数据 A 加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK 中的 synchronized 和 JUC 中 Lock 的实现类就是互斥锁。

共享锁是指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

7. JUC

JUC就是 java.util.concurrent 工具包的简称。在此包中增加了并发编程常用工具类,包括线程池,异步 IO 和轻量级任务框架,还提供了设计用于多线程上下文中的 Collection 实现等。

目的就是为了更好的支持高并发任务,让开发者利用这个包进行多线程编程时可以有效地减少竞争条件和死锁线程。

按照功能可以大致划分如下:
juc-locks 锁框架
juc-atomic 原子类框架 q juc-sync(tools) 同步器框架
juc-collections 集合框架
juc-executors 执行器框架

7.1. AQS(AbstractQueuedSynchronizer)

https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20%E5%B9%B6%E5%8F%91.md#%E4%B8%83juc---aqs https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html

  • CountDownLatch 用来控制一个或者多个线程等待多个线程。

  • CyclicBarrier 用来控制多个线程互相等待,只有当多个线程都到达时,这些线程才会继续执行。

  • Semaphore Semaphore 类似于操作系统中的信号量,可以控制对互斥资源的访问线程数。

7.2. FutureTask

FutureTask 可用于异步获取执行结果或取消执行任务的场景。当一个计算任务需要执行很长时间,那么就可以用 FutureTask 来封装这个任务,主线程在完成自己的任务之后再去获取结果。

7.3. BlockingQueue

阻塞队列 java.util.concurrent.BlockingQueue 接口有以下的实现:

  • ArrayBlockingQueue,数组结构,有界,固定长度
  • LinkedBlockingQueue,链表结构,内部以 node 结点类来存储元素,有固定大小
  • PriorityBlockingQueue,优先级队列,底层使用 数组存储,以来 Comparator 来实现优先级
  • DelayQueue,延迟队列,底层存放了 Delayed 接口的对象
  • SynchronousQueue,无缓冲的等待队列,内部只能容纳单个元素,生产者和消费者缺一就阻塞

BlockingQueue 有4种不同的方法来插入、删除和检查队列中的元素。每一组方法的行为都是不同的,以防被请求的操作不能立即执行。下面是这些方法的一个表:

Throws Exception 特殊值 阻塞 超时
Insert add(o) offer(o) put(o) offer(o, timeout, timeunit)
Remove remove(o) poll() take() poll(timeout, timeunit)
Examine element() peek()

对于阻塞的 take() 和 put() 方法,如果队列为空 take() 将阻塞,直到队列中有内容;如果队列为满 put() 将阻塞,直到队列有空闲位置。

7.4. ForkJoin

主要用于并行计算中,和 MapReduce 原理类似,都是把大的计算任务拆分成多个小任务并行计算。

ForkJoin 使用 ForkJoinPool 来启动,它是一个特殊的线程池,线程数量取决于 CPU 核数。

public class ForkJoinPool extends AbstractExecutorService
参考 https://github.com/CyC2018/CS-Notes/blob/master/notes/Java%20%E5%B9%B6%E5%8F%91.md#%E5%85%ABjuc---%E5%85%B6%E5%AE%83%E7%BB%84%E4%BB%B6

8. 并发编程与扩展

8.1. 对高并发的理解

实际的开发中常见的可能存在高并发的场景以及解决方式: - web 端控制控制输入流,每次点击结束有个等待的过程,避免客户端重复点击,造成没有必要的浪费 - 服务端加服务器,Nginx 做负载均衡处理,进行分流处理,多台服务器去处理要求 - 存储和获取数据使用 Redis 等缓存,经常被查询的数据使用通过 Redis 避免多次查询数据库 - 数据库实行读写分离处理,减轻数据库的负担

8.2. 从水平扩展和垂直扩展来提高并发能力

水平扩展。使用分布式,增加服务器数量。 - 反向代理层使用 DNS 轮询进行水平扩展 - 站点使用 Nginx 进行水平扩展 - 服务层使用服务连接池进行水平扩展 - 数据库使用哈希的方式进行水平扩展

垂直扩展 - 增强单机性能 - 提升单机架构性能,如使用缓存、异步和无锁结构减少响应时间

8.3. 多线程开发良好的实践

  • 给线程起个有意义的名字,这样可以方便找 Bug。

  • 缩小同步范围,从而减少锁争用。例如对于 synchronized,应该尽量使用同步块而不是同步方法。

  • 多用同步工具少用 wait() 和 notify()。首先,CountDownLatch,CyclicBarrier,Semaphore 和 Exchanger 这些同步类简化了编码操作,而用 wait() 和 notify() 很难实现复杂控制流;其次,这些同步类是由最好的企业编写和维护,在后续的 JDK 中还会不断优化和完善。

  • 使用 BlockingQueue 实现生产者消费者问题。

  • 多用并发集合少用同步集合,例如应该使用 ConcurrentHashMap 而不是 Hashtable。

  • 使用本地变量和不可变类来保证线程安全。

  • 使用线程池而不是直接创建线程,这是因为创建线程代价很高,线程池可以有效地利用有限的线程来启动任务。