黑马Java多线程笔记

395 阅读14分钟

1. 概述

2. 进程与线程

2.1 程序,进程与线程

  • 程序:由指令和数据组成,是静态的。
  • 进程:程序中的指令需要加载到CPU,数据需要加载至内存。进程就是用来加载指令,管理内存。当一个程序被运行,从磁盘中加载这个程序到内存中就开启了一个进程程。
  • 线程:一个进程之内有一到多个线程。Java0中,线程作为最小调度单位,进程作为资源分配的最小单位

进程 VS 线程

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集。
  • 进程拥有共享的资源。

3. Java线程

3.1 三种创建方法

  • 继承 Thread 类并重写 run 的方法:
    • 优势:
      • 在 run() 方法内获取当前线程直接使用 this 就可以了,无须使用 Thread.currentThread() 方法。
    • 缺点:
      • 不能继承其它类。
      • 任务和代码不分离,多个线程执行一样的任务时需要多份任务代码(需要在run方法中写同样的代码)。
  • 实现 Runnable 接口的 run 方法,传给 Thread :
    • 优势:
      • 任务和代码分离。
      • 可以继承其他类。
  • 实现Callable 接口的 call() 方法,传给 FutureTask :
    • 优势:拥有返回值。

3.2 start 与 run

  • 直接调用 run 是在主线程中执行了 run,没有启动新的线程。
  • 使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码。

3.3 sleep 与 yield

sleep

  1. 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)。
  2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
  3. 睡眠结束后的线程未必会立刻得到执行,虽然锁没用释放但是也需要去抢占时间片。
  4. 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性,因为带时间单位。 yield
  5. 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程。
  6. 具体的实现依赖于操作系统的任务调度器。
  7. 会让出CPU给其他线程。
  8. 当没有其他线程可让时候当前线程会继续执行。

3.4 join方法

  • 作用:用于线程同步。
  • join 方法是 Thread 类直接提供的。join 是无参且返回值为 void 的方法.会使得调用线程执行完成后继续执行。

3.5 Interrupt

  • 调用线程的interrupt方式并不是会结束线程,而是会设置线程的打断标记为true,我们需要自行对打断标记进行相应的动作。
  • 但是当线程处于sleep、wait、join状态时使用interrruop方法会抛出InterruptedException异常,清空打断状态设置为false。
  • 打断正常运行的线程, 不会清空打断状态。

Interrupt打断LockSupport.park() 使用interrupt方法打断park方法时不会抛出InterruptedException异常也不会重置打断标记为false,而是把打断标记设置为ture,但是当使用park方法被打断后再使用LockSupport.park()就不会生效,我们需要使用interrupted方法重置打断标记为false后再使用LockSupport.park()才会生效。

3.6 两阶段终止模式之如何”优雅“的终止线程

1. 错误思路

  • 使用线程对象的 stop() 方法停止线程 stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁。
  • 使用 System.exit(int) 方法停止线程目的仅是停止一个线程,但这种做法会让整个程序都停止。

2. 两阶段终止模式

  • 利用 isInterrupted 方法来判断结束的时机,然后料理后事。

3.7 守护线程与用户线程

Java 中的线程分为两类,分别为 daemon 线程(守护线程)和 user 线程(用户线程)。两者区别之一是当最后一个非守护线程结束时,JVM 会正常退出,垃圾回收线程就是一个守护线程。

3.8 线程的六种状态

NEW、RUNNABLE、TERMINATED、BLOCKED、WAITINF、TIMED_WAITING

  • NEW: 创建了线程而未调用start方法
  • RUNNABLE:线程得到了CPU的分片处于运行状态
  • TERMINATED:执行结束的线程处于这一状态
  • BLOCKED:当线程去申请其他线程已经在使用的独占锁时,会处于这一状态
  • WAITINF:调用wait、join等方法时,会进入到这一状态
  • TIMED_WAITING:与WAITINF类似,只不过不是无限制的等待,如sleep()

4. 共享模式之管程

4.1 Monitor 概念

Java 对象头格式:根据不同的状态 Mark Word 也不相同 image.png

  • Monitor 被翻译为监视器或管程
  • 每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针 Monitor 结构如下:
  • WaitSet:等待队列,线程调用 wait() 方法后会进入到此队列。
  • EntrtList: 堵塞队列,抢占不到锁时,就会进入到此队列。
  • Owner: 指向拥有锁的线程,只能有一个线程拥有锁。 image.png
  • 刚开始 Monitor 中 Owner 为 null。
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中能有一个 Owner。
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList BLOCKED
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,调用了 wait() 方法,等待其他线程的 notify。

4.2 synchronized 原理

4.2.1 偏向锁

偏向锁的使用场景:只有一个线程持有过这个锁,多个线程错开加锁就会升级为轻量级锁。

4.2.2 轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争)。

4.2.3 重量级锁

重量级锁的使用场景:多个线程对同一个对象锁发生竞争。

4.2.4 锁膨胀

偏向锁多个线程错开加锁时会膨胀为轻量级锁,如果在尝试加轻量级锁的过程中,发现有多个线程产生竞争时,这时需要进行锁膨胀,申请 Mointor 将轻量级锁变为重量级锁。

4.2.5 自旋优化

当线程去访问同步块,获取 monitor 时发现 Owner 不为 null,这时不会直接进入到 EntryList 中,而会自旋几次尝试去获得锁。

4.3 wait/notify/notifyAll

**这三个方法必需在拥有锁的情况下才能使用,不然会抛出异常IllegalMonitorStateException。**obj为锁对象。

  • obj.wait() 让进入 object 监视器的线程到 waitSet 等待。
  • obj.notify() 在 object 上正在 Monitor 中的 waitSet 等待的线程中 挑一个(不按照 wait 顺序) 唤醒。
  • obj.notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒。

wait 和 sleep 的区别

  • sleep 是 Thread 方法,而 wait 是 Object 的方法 。
  • wait会释放锁,sleep不会释放锁。
  • sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用。

wait 正确的使用方式:

synchronized(lock) {
    while(条件不成立) {
        lock.wait();
    }
    // 干活
}
//另一个线程
synchronized(lock) {
    lock.notifyAll();
}

4.4 LockSupport

  • 暂停当前线程:LockSupport.park()
  • 恢复某个线程的运行:LockSupport.unpark(暂停线程对象)

park 和 unpark VS wait 和 notify

  • park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程.
  • park & unpark 可以先 unpark,而 wait & notify 不能先 notify
  • wait,notify 和 notifyAll 必须配合锁对象一起使用,而 park,unpark 不必。

7. 死锁/活锁/饥饿

  • 死锁: t1 线程获得 A 对象锁,接下来想获取B对象的锁 t2 线程获得 B 对象锁,接下来想获取 A 对象的锁。
  • 活锁: 活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束。
  • 饥饿: 一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束。

8. ReentrantLock

ReentrantLock有以下特点:

  • 可重入: 重入是指同一个线程如果首次获得了这把锁,那么它成为这把锁的拥有者,再次去申请这把锁时不再受到阻碍。如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住。
  • 可打断: ReentrantLock提供了lockInterruptibly方法,可以使用interrupt()去打断。
  • 锁超时: ReentrantLock提供了tryLock方法,尝试去得到锁,得到返回true,失败为false。
  • 公平锁: 通过构造函数传递参数 ture 可以设置为公平锁;公平锁按照先后顺序来调度线程,而非公平锁可能最后到的最先执行。
  • 条件变量(Condition): synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待,ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的。

ReentrantLock VS synchronized

  • 可中断。
  • 可以设置超时时间。
  • 可以设置为公平锁。
  • 支持多个条件变量。

5. 共享模型之内存

5.1 可见性

  • 因为高速缓存的存在,线程 A 对一个变量的修改,线程 B 可能看不到这次的修改,因为线程 A 的修改后的数据可能没返回到主存中。

5.2 原子性

  • 操作具有不可分割性,在整个原子性的操作中,不允许其他线程介入。 原子性 + 原子性 ≠ 原子性

5.3 有序性

  • JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,也就是指令重排

6. 共享模型之无锁

6.1 volatile

可以修饰成员变量和静态成员变量,保障可见性、有序性和原子性。

6.2 内存屏障

  • 可见性
    • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中。
    • 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。
  • 有序性
    • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
    • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。

volatile 的底层实现原理是内存屏障

  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障

6.2 CAS

CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果
CAS 的特点:

  • 结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。
  • CAS 是基于乐观锁的思想:当其他线程来修改共享变量后,自己会进行重试进行修改。
  • CAS 体现的是无锁并发、无阻塞并发。
    • 因为没有使用 synchronized,所以线程不会陷入阻塞
    • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

7 共享模型之工具

7.1 线程池

ThreadPoolExecutor 使用 int 的高 3 位来表示线程池状态,低 29 位表示线程数量。将线程池状态和数量放在同一个变量中,是为了保护原子性,让修改线程池和数量时不用分成两步。

状态名高3位说明
RUNNING111运行状态
SHUTDOWN000不会接收新任务,但会处理阻塞队列剩余任务
STOP001会中断正在执行的任务,并抛弃阻塞队列任务
TIDYING010任务全执行完毕,活动线程为 0 即将进入终结
TERMINATED011终结状态

7.1.1 线程池构造方法

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
  • corePoolSize 核心线程数目 (最多保留的线程数)
  • maximumPoolSize 最大线程数目
  • keepAliveTime 生存时间 - 针对救急线程
  • unit 时间单位 - 针对救急线程
  • workQueue 阻塞队列
  • threadFactory 线程工厂 - 可以为线程创建时起个好名字
  • handler 拒绝策略
  1. 线程池中刚开始没有线程,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。
  2. 当线程数达到 corePoolSize 并没有线程空闲,这时再加入任务,新加的任务会被加入 workQueue 队列排队。
  3. 如果等待队列选择了有界队列,那么任务超过了队列大小时,会创建 (maximumPoolSize - corePoolSize) 数目的救急线程来使用。
  4. 如果线程到达 maximumPoolSize 仍然有新任务这时会执行拒绝策略。拒绝策略 jdk 提供了 4 种实现。
    • AbortPolicy 让调用者抛出 RejectedExecutionException 异常,这是默认策略
    • CallerRunsPolicy 让调用者运行任务
    • DiscardPolicy 放弃本次任务
    • DiscardOldestPolicy 放弃队列中最早的任务,本任务取而代之

7.1.2 线程池的三个实现

- newFixedThreadPool

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}

特点:

  • 核心线程数 == 最大线程数(没有救急线程被创建),因此也无需超时时间
  • 阻塞队列是无界的,可以放任意数量的任务

评价:适用于任务量已知,相对耗时的任务

- newCachedThreadPool

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                 60L, TimeUnit.SECONDS,
                                 new SynchronousQueue<Runnable>());
}

特点:

  • 核心线程数是 0, 最大线程数是 Integer.MAX_VALUE,救急线程的空闲生存时间是 60s,意味着全部都是救急线程(60s 后可以回收)
  • 队列采用了 SynchronousQueue 实现特点是,它没有容量,没有线程来取是放不进去的(一手交钱、一手交货),因为没有核心线程来取,这样队列中不会有线程,那么保证了所有线程都是救急线程。

评价:适合任务数比较密集,但每个任务执行时间较短的情况

- newSingleThreadExecutor

public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(
            1, 1,
            0L, TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<Runnable>()));
}

特点:

  • 线程数固定为 1,任务数多于 1 时,会放入无界队列排队。任务执行完毕,这唯一的线程也不会被释放。
  • 与newFixedThreadPool(1) 的区别:FinalizableDelegatedExecutorService 应用的是装饰器模式,只对外暴露了 ExecutorService 接口,因此不能调用 ThreadPoolExecutor 中特有的方法,而 newFixedThreadPool(1) 初始时为1,以后还可以修改

评价:适合串行执行的场景。

7.2 J.U.C

7.2.1 AQS原理

AQS概述 全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具实现的同步器框架。 特点: -用 state 属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁。例如:state为0共享模式,1为独占模式。 - compareAndSetState - cas 机制设置 state 状态。 - 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源。

  • 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList,一个线程占有这个锁后,其它线程就会进入等待队列,进入等待。
  • 条件变量来实现等待、唤醒机制,支持多个条件变量。

关键点:

  • 原子维护 state 状态
  • 堵塞及恢复线程
  • 维护队列

7.2.8 线程安全类集合概述

分为三大类:

  • 遗留的线程安全集合如Hashtable,Vector
    • Hashtable:hashMap的安全集合实现,内部使用synchronized实现,并发性能低。
    • Vector:list的安全集合的实现,
  • 使用 Collections 装饰的线程安全集合:
    • Collections.synchronizedCollection
    • Collections.synchronizedList
    • Collections.synchronizedMap
    • Collections.synchronizedSe
  • java.util.concurrent.*
    • Blocking大部分实现基于锁(ReentrantLock),不满足条件时阻塞。如BlockingQueue。
    • CopyOnWrite对于修改的开销很大。
    • Concurrent类型的容器,内部大部分使用cas操作优化,提高并发量。具有一定的弱一致性,当然弱一致性和并发性是不可共存的。

7.2.11 CopyOnWriteArrayList

  • 底部是采用写入时拷贝的思想。
    • 写入时拷贝:增删改操作会将低层数组拷贝一份,其操作都会在新数组上执行,这时其他线程可以并发读,只不过在复制的过程中,得到的是旧数据。
  • 适合读多写少的应用场景
  • CopyOnWriteArraySet内部也是使用CopyOnWriteArrayList实现的。