总结之多线程


前言

本篇博客主要是对Java并发编程重要知识点的总结。包括线程、进程、线程池、Java锁、重要关键字说明以及之间的对比。


一、线程

1.线程和进程

程序 = 指令 + 数据

进程:
 创建:当一个程序被运行,从磁盘加载这个程序代码至内存,这时就开启了一到多个进程。
 作用:加载指令、管理内存、IO等。
 程序与进程的关系:大部分程序可以同时运行多个进程,也有程序只能启动一个实例进程。
 地位:进程作为资源分配的最小单位。

线程:
 一个进程之内可以分为一到多个线程。
 作用:一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给CPU执行。
 地位:线程作为最小调度单位。

进程 VS 线程:
 1.进程基本上相互独立。而线程存在与进程内,是进程的一个子集。
 2.进程拥有共享的资源,如内存空间等,供其内部线程共享。线程只独享指令流执行的必要资源。
 3.进程间的通信比较复杂。线程通信相对简单,它们共享进程内的内存。
 4.线程更轻量,线程上下文切换成本一般比进程上下文切换低。

2.并发和并行

并发: 单核CPU下,同一时刻轮流使用CPU的做法叫做并发。
并行: 多核CPU下,每个核都可以调度运行线程,这时线程是并行的。
 多核CPU下 并发 和 并行 共存。多核CPU下多线程并行才可以提高效率,单核CPU下多线程微观上还是串行的。

3.创建线程的方法

 (1)继承Tread类。
 (2)实现Runnable/Callable接口的方式。
   Runnable VS Callable:
   Callable规定重写方法是call(),可以抛出异常,任务执行后有返回值。Runnable规定重写方法是run(),不能抛出异常,执行后没有返回值。
 (3)通过线程池的方式

4.线程的状态

4.1 操作系统层面

在这里插入图片描述

创建: 仅在语言层面创建了线程对象,还未与操作系统线程相关联。
就绪: 该线程已被创建且与操作系统相关联,可以由CPU调度执行。
运行: 获取了CPU时间片并处于运行代码的状态。
阻塞: 调用了阻塞API(如读写文件),导致线程上下文切换,进入阻塞状态。等待操作完毕,会由操作系统唤醒阻塞线程转至可运行。
终止: 线程已经执行完毕,生命周期已经结束。

4.2 JavaAPI层面

Java线程new: 线程刚被创建,但还没有调用start()方法。
Runnable: 调用了start()方法之后,此时线程才能真正执行。
   Java API层面的 Runnable状态 涵盖了操作系统层面的 可运行、运行和操作系统的阻塞状态。
(Blocked、Waiting、Timed_Waiting都是Java对阻塞状态的细分)
Blocked: 阻塞状态,表示线程阻塞与锁。
Waiting: 等待状态,表示当前线程需要等待其他线程做出通知或中断。
Time_Waiting: 超时等待状态,该状态不同于Waiting的地方是,可以在指定的时间自行返回。
Terminated: 终止状态,表示当前线程已经执行完毕。

5.常见方法

5.1 start & run

 run方法称为线程体,直接调用run方法,并不能启动新的线程。启动线程必须调用start方法,同一个线程只能调用一次start。

5.2 sleep VS yield

(Tread.sleep(n)在哪个线程调用,该线程睡眠)
sleep:
  (1)调用sleep方法会让线程从Runnable到Timed_Waiting。
  (2)其他线程可以使用interrupt方法打断正在睡眠的线程,这时会抛出InterruptedException异常。
  (3)睡眠结束后的线程未必会立即执行,需要重新争夺CPU,获取到时间片后才能执行。

yield
  (1)调用yield方法让当前线程让出CPU,状态变为可运行,然后调度执行其他线程。
  (2)可能立即又被分配到时间片。

5.3 sleep VS wait

共同点:都是让当前线程放弃CPU,进入阻塞状态。
不同点:
  (1)方法归属不同
    sleep()是Tread的静态方法。
    wait()是Object的成员方法,每个对象都有。
  (2)醒来的时机不同
   执行sleep(long)和wait(long)的线程都会在等待相应毫秒后想来
    wait(long)和wait()还可以被 notify 唤醒。
   都可以调用interrupt方法打断唤醒。
  (3)锁特性不同
    wait方法的调用必须先配合synchronized一起获得到wait对象的锁。
    wait方法执行后会释放对象锁,允许其他线程获得对象锁。
    而sleep方法没有释放锁

6.线程的活跃性

 (1)死锁:互相等待对方已经持有的锁,参与的线程都会被阻塞。
  解决:顺序加锁(容易产生 饥饿)、ReentrantLock(tryLock)

 (2)活锁:出现在两个线程互相改变对方的结束条件,最后谁也无法结束,但并未阻塞。
  解决:让它们的执行时间有一定的交错(增加随机睡眠)

 (3)饥饿:一个线程由于优先级太低,始终得不到CPU调度的执行,也不能够结束。
  解决:ReentrantLock设置公平锁

7.守护线程

 默认情况下,Java进程需要等待所有线程都运行结束,才会结束。
 有一种特殊的线程 守护线程,为用户线程提供公共服务,只要其他非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。守护线程中产生的新线程也是守护线程。

 实现:在启动之前将 线程.setDaemon(true),该线程即为守护线程。

 应用:垃圾回收器线程,当垃圾回收器线程是JVM上仅存的线程时,垃圾回收器线程会自动离开。

8.终止线程的四种方式

1.正常运行结束。
2.自定义退出标志退出线程。
3.interrupt()方法结束线程。
  (1)线程处于阻塞状态,调用interrupt方法会抛出InterruptException异常,然后break才能跳出循环结束。
  (2)线程未处于阻塞状态:使用isInterrupted()判断线程的中断标志来退出循环。
4.stop方法终止线程。
  调用stop后导致该线程持有的锁的突然释放,锁保护的数据就可能呈现不一致性。是线程不安全的。

二、锁

1.线程安全

线程安全:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

1.1 临界区

 一段代码内如果存在对 共享资源 的多线程读写操作成这段代码块为临界区。

如何实现线程安全?
  阻塞式(锁synchronized、lock)、非阻塞式(原子变量)。

1.2 变量的线程安全分析

成员变量和静态变量是否线程安全:
  (1)如果过他们没有被共享,则线程安全。
  (2)如果他们被共享,如果只有读操作,线程安全。如果有读写操作(临界区),需要考虑线程安全。
局部变量是否线程安全:
  (1)局部变量是线程安全的。
  (2)局部变量引用的对象未必线程安全。如果该对象没有逃离该局部的范围,是线程安全的。否则需要考虑线程安全。

1.3 JMM

原子性

 原因:多线程下,不同线程的 上下文切换 引起指令交错,导致的共享变量的读写混乱。
 解决:synchronized、Lock、CAS(volatile无法保证原子性)

可见性

 原因:由于编译器优化、缓存优化或者CPU指令重排序优化导致的对共享变量所做的修改另外的线程看不到。
 解决:volatile

有序性

 原因:由于编译器优化、缓存优化或者CPU指令重排序优化导致指令的时机执行顺序与编写顺序不一致。
 解决:volatile

1.4 常见的线程安全类

 String、Integer等的包装类。StringBuffer、Random、Vector、Hashtable、JUC包下的类。

2. 两种锁的对比

2.1 synchronized

 使用 对象锁 保证了 临界区 代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换打断。

 **线程八锁:**考察synchronized锁住的是哪个对象。
  1.作用与成员变量时,锁住的是this对象。
  2.方法上的synchronized
   (1)成员方法:相当于锁住的是this对象。
   (2)静态方法:相当于锁住的是类对象class。
  3.synchronized作用于一个代码块时,锁住的是所有代码块中配置的对象。

synchronized锁升级

锁状态:无锁状态、偏向锁、轻量级锁、重量级锁。
 (1)无锁
  当一个对象被创建之后,还没有线程进入,这个时候对象处于无锁状态,其Mark Word中的信息如上表所示。

 (2)偏向锁
  经过研究发现,大多数情况下不存在锁的竞争,常常是一个线程多次获得同一个锁。这种情况下,每次锁竞争会付出很多不必要的代价,为了降低获取锁的代价,引入了偏向锁。

  重偏向:若对象虽然被多个线程访问,但没有竞争,这时偏向某线程的对象仍有机会偏向其他线程,重偏向会重置对象的Tread ID。

  批量重定向:当撤销偏向锁阈值超过20次之后,JVM觉得可能偏向了一个不适合的线程,于是会在给这些对象加锁时重偏向至现加锁线程,而不是升级为轻量锁。

  批量撤销:当撤销偏向锁阈值超过40次之后,JVM会觉得确实偏向错了,根本就不该对该类对象进行偏向,于是该类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的,锁升级为轻量级锁

 (3)轻量级锁
  考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景,减少传统的重量级锁加锁、解锁产生的性能消耗。通过CAS实现。

  自旋锁:通过CAS尝试加轻量级锁,如果CAS操作成功,则加锁成功。如果CAS失败,则认为锁被占用,继续自旋式的等待。(多核CPU下自旋才能发挥优势)

  锁膨胀:若在尝试加轻量级锁的过程中,cas操作无法成功,且自旋已经达到了一定的次数,这时需要进行锁膨胀,将轻量级锁升级为重量级锁。

 (4)重量级锁
  依赖操作系统的Mutex Lock实现的,这时线程切换就需要从用户态装换到和心态,成本高。在内核态判定当前锁是否已经被占用,如果没有,则加锁成功并切换回用户态。否则,加锁失败,线程进入锁的等待队列(EntryLIA)


总结
 轻量级锁对于重量级锁有了一定的优化,但是轻量级锁在没有竞争的时候,每次冲入仍然要指向cas操作,Java6中引入了偏向锁做进一步优化。

其他的锁优化
锁消除

  JVM在JIT编译优化时,通过对上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,节省毫无意义的加、解锁的资源消耗。

锁粗化

 一段逻辑中如果出现多次加锁、解锁,其本身会消耗资源,不利于性能的优化。会自动进行锁的粗化。

2.2. ReentrantLock(JUC)

 ReentrantLock是一种可重入锁,除了能完成synchronized所能完成的所有工作外,还提供了 可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。

可重入锁

  同一线程如果首次获得了这把锁,因为它是锁的持有者,有权再次获取这把锁。

不可重入锁
  第二次获得锁的时候自己也被挡住。

非公平锁

  ReentrantLock在构造函数中提供了是否公平锁的初始化方式,默认是非公平锁。
  JVM按随机分配锁的机制吃呢个位非公平锁。非公平锁实际的执行效率远远超出公平锁,除非有特殊需要才会使用公平锁。

公平锁

  锁的分配机制是公平的,通常先对锁提出请求的线程会先被分配到锁。

2.3 JUC下的锁 VS synchronized

 共同点:
  (1)都是用来协调多线程对共享对象、变量的访问。
  (2)都是可重入锁。
  (3)都保证了 可见性。

 不同点:
  (1)JUC下的锁 需要显示的获得、释放锁。synchronized 隐式的获得释放锁。synchronized 在发生异常时,会自动释放线程战=占有的锁,前者发生异常时没有主动释放锁,可能会造成死锁现象。
  (2)JUC下的锁 书写更加灵活,可以一个方法加锁,另一个方法中解锁。
  (3)JUC下的锁 是API级别的。synchronized 是JVM级别的。
  (4)JUC下的锁 锁的类型更加灵活。synchronized 只有一种类型的锁。
  (5)JUC下的锁可以让等待锁的线程响应中断,而synchronized 不行。

3. volatile

3.1 原理

  volatile的底层时间原理是内存屏障
  (1)对volatile变量的写指令之后会加入写屏障。
  (2)对volatile变量的读指令之前会加入读屏障。

如何保证 可见性 ?
 (1)写屏障之前保证在该屏障 之前 的对共享变量的改动,都同步到主存中。
 (2)读屏障保证在该屏障 之后 的对共享变量的读取加载的都是内存中最新的数据

如何保证 有序性?
 (1)写屏障会确保指令重排时,不会将写屏障之前的代码排在写屏障之后。
 (2)读屏障会确保指令重排时,不会将读屏障之后的代码排在读屏障之前。

内存屏障并不能解决原子性问题(指令交错):
 (1)写屏障仅仅保证之后的读能够读到最新数据,而不能保证读跑到它前面去。
 (2)有序性的保证也只能保证是本线程内的相关代码不被重排序。

4.CAS

4.1 CAS特点

 (1)CAS可以保证 原子性。
 (2)CAS + volatile 可以实现无锁并发,适用于线程少、多核CPU下。
 (3)CAS是基于乐观锁的策略实现的。
 (4)CAS体现的是无锁并发。
  1)没有使用synchronized等锁,线程不会陷入阻塞。
  2)如果竞争激烈,重尝试必然频繁发生,不利于线程。

4.2 乐观锁 和 悲观锁 思想

乐观锁
  核心思想:无需加锁,每次只有一个线程能成功修改共享变量,其他失败的线程不需要停止,不断重试直至成功。
  代表:CAS

悲观锁
  核心思想:线程只有占有了所,才能去操作共享变量,每一次都只有一个线程成功。获取锁失败的线程,都得停下来等待。
  代表:synchronized 和 Lock 锁

4.3 使用CAS实现的工具类(JUC)

 (1)原子整数:AtomicBoolean、AtomicInteger、AtomicLong
 (2)原子引用:AtomicReference、AtomicMarkableReference、AtomicStampedReference.
 (3)原子数组:AtomivIntegerArray、AtomicLongArray、AtomicReferenceArray

4.4 CAS导致的ABA问题

 线程只能判断共享变量的值与最初值是否相同,不能感知到A->B->A的情况。
解决: 加一个版本号

5. 其他

ReadWriteLock 读写锁

 为了提高性能,在读的地方使用读锁,在写的地方使用写锁,灵活控制,在一定程度提高效率。
 多个读锁不互斥、读锁与写锁互斥。

独占锁 和 共享锁 加锁模式

 独占锁模式:每次只能有一个线程持有锁,是一种悲观的加锁策略。
 共享锁模式:允许多个线程同时获取锁,并发访问共享资源。

三、Java阻塞队列

1. Java阻塞队列原理

生产者-消费者
 1.当队列中没有数据的情况下,消费者段所有线程都会被挂起,直到有数据放入队列。

 2.当队列填满数据的情况下,生产者段的所有线程都会被挂起,直到队列中有空位,线程被自动唤醒。

2.主要方法

方法类型抛出异常返回特殊值超时
插入add(e)offer(e)put(e)
移除remove()poll()take()

3. Java中常见的阻塞队列

 1.ArrayBlockingQueue:由数组结构组成的有界阻塞队列。
 2.LinkedBlockingQueue:由链表组成的有界阻塞队列。
 3.PriorityBlockingQueue:支持优先级排序的无界阻塞队列。
 4.DelayQueue:是一个实现PriorityBlockingQueue的延迟获取的无界队列。
 5.SynchronousQueue:不存储任何元素的阻塞队列,每一个put操作必须等待take操作,可用于传递数据。

四、线程池

 线程池特点:线程复用、控制最大并发数、管理线程。

1.线程池的基本组成

 (1)线程池管理器:用于创建并管理线程池。
 (2)工作线程:线程池中的线程。
 (3)任务接口:每个任务必须实现的接口,用于工作调度其运行。
 (4)任务队列(阻塞队列):用于存放待处理的任务,提供一种缓冲机制。

2.线程池的创建

 (1)通过Executors工厂类创建,创建方式较简单,但定制能力有限。
 (2)通过ThreadPoolExcutor创建,创建方式比较复杂,但定制能力强。

3.Excutors创建的四种线程池

线程池的顶级接口是Excutors,真正的线程池接口是ExcutorService.

3.1 newCachedThreadPool

 线程池大小不固定,可灵活回收空闲线程。若无可回收,则新建线程。

3.2 newFixedThreadPool

 固定大小的线程池,当有新任务提交,线程池中如果有空闲线程,则立即执行。否则新任务会被缓存在一个任务队列中,等待线程池释放空闲线程。

3.3 newSechedThreadPool

 定时线程池,支持定时及周期性任务执行。

3.4 newSingleThreadPool

 只创建一个线程,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。

4. ThreadPoolExecutor 线程池7大核心参数

 1.**corePoolSize:**指定线程池中的线程数量。
 2.**maximumPoolSize:**指定了线程池中最大线程数量。
 3.**keepAliveTime:**多余线程的存活时间,当前线程数大于 corePoolSize,并且等待时间大于 keepAliveTime,多余线程可能被销毁直到剩下corePoolSize为止。
 4.TimeUnit unit: keepAliveTime的时间单位。
 5.**workQueue:**任务队列,被提交但未被执行的任务。
 6.**threadFactory:**线程工厂,用于创建线程。
 7.**handler:**拒绝策略,当任务队列满了并且工作线程大于线程池的最大线程数,如何拒绝任务。

4.1 拒绝策略

 1.AbortPolicy:线程池的默认拒绝策略。直接丢弃任务并抛出异常。
 2.DiscardPolicy:丢弃任务,但不抛出异常。
 3.DiscardOldestPolicy:丢弃任务队列最前面的任务,不抛出异常,然后重新提交当前任务。
 4.CallerRunsPolicy:由调用线程处理该任务。任务提交线程性能极有可能几句下降。

4.2 Java线程池的工作原理

 1.线程池创建时,里面没有一个线程,任务队列作为参数传入。
 2.当调用 execute() 方法添加一个任务时,线程池有如下判断:
  (1)如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务,使用核心线程数。
  (2)如果正在运行的线程数量大于或等于 corePoolSize,将这个任务队列放入任务队列。
  (3)如果这时候队列满了,而且正在运行的线程数小于 maximumPoolSize ,那么创建非核心线程执行这个任务。
  (4)如果队列满了,而且正在运行的线程数量等于 maximumPoolSize,那么线程池,就执行相应的拒绝策略。
 3.当一个线程完成任务后,从队列中取下一个任务来执行。
 4.当一个线程空闲,超过 keepAliveTime 时,线程会判断,如果当前运行的线程数大于corePoolSize ,那么该线程会被销毁。当线程池中所有任务都执行完毕后,线程数最大会收缩到 corePoolSize 大小。

补充:Hashtable VS ConcurrentHashMap

 相同点:Hashtable 和 ConcurrentHashMap 都是线程安全的Map集合。
 不同点:
  Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时间,这能有一个线程操作它。ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不同锁,就不会冲突。

ConcurrentHashMap 1.7 VS 1.8

  ConcurrentHashMap1.7
  饿汉式初始化。
  数据结构:Segment(大数组) + HashEntry(小数组) + 链表。每个segment对应一把锁,多个线程访问不同的segment不会冲突。
  并发度:segment数组的个数决定了同时多少个线程访问。
  扩容条件:小数组在超过扩容因子时会触发扩容,每次扩容翻倍。

  ConcurrentHashMap1.8
  懒汉式初始化。
  数据结构:Node数组 + 链表 + 红黑树,数组的每个头结点作为锁,如果多个线程访问的头结点不同,不会产生冲突。
  并发度:Node数组的大小。Node数组可以扩容。
  扩容条件:Node数组的元素个数满 3 / 4 时就会扩容,容量翻倍。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值