多线程

一、线程和进程

进程

程序的一次执行过程,是系统运行运行程序的基本单位。系统运行一个程序从一个进程创建,运行到消亡的过程。在java中,启动main函数就启动了一个JVM进程,main函数所在的线程是进程中的一个线程,也称为主线程。

线程

与进程相似,但是比进程更小。一个进程多个线程。多个线程共享进程的堆和方法区资源,每个线程有自己的程序计数器,虚拟机栈与本地方法栈。线程之间切换负担比较小,因此线程被称为轻量级进程。

一个Java程序的运行是main线程和多个其他线程同时运行。

线程和进程的关系

java虚拟机-第 2 页

再把JVM的这张图拿出来, 一个进程中有多个线程,线程之间共享进程的堆和方法区,但是对于每个线程,都有自己的虚拟机栈,程序计数器,本地方法栈。

  • 程序计数器为什么私有?

    程序计数器的作用:

    • 字节码解释器通过改变程序计数器来以此读取指令,从而实现代码的流程控制。
    • 多线程时,程序计数器记录当前线程的执行位置,从而当线程被切换回来的时候知道线程上次运行到哪里了。

    如果执行的是native方法, 程序计数器记录的是undefined地址,执行java代码时程序计数器记录的才是下一条指令的地址。

    所以程序计数器是私有是为了线程切换后能恢复到正确的执行位置

  • 虚拟机栈和本地方法栈为什么私有?

    • stack:JVM启动时,分配独立的运行时栈,保存方法调用,每次调用入栈。栈保存了三个引用:本地变量表,操作数帧和当前方法所属类的运行时常量池。
    • native method stack:本地方法栈和虚拟机栈的作用相似,虚拟机执行的是字节码,本地方法栈执行的是native方法。本地方法栈使用传统的栈来支持native方法。

    所以为了保存内存中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

并发和并行?

  • 并发:同一时段内,多个任务都在执行。
  • 并行:单位时间内,多个任务同时执行。

为什么使用多线程

  • 计算机底层:轻量级的进程,切换的调度的成本远远低于进程。另外多核CPU时代意味着多个线程可以同时运行,减少了线程上下文切换的开销。
  • 趋势:百万级别的并发量,高并发系统的基础。

多线程可能带来的问题:

但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏上下文切换死锁

线程的生命周期和状态

6种状态:

  • NEW:初始状态,线程被构建,但是还没有调用start方法
  • RUNNABLE:运行状态,java线程将被OS的就绪和运行两种状态笼统的称为“运行中”。
  • BLOCKED:阻塞状态
  • WAITING:等待状态,当前线程需要等待其他线程做出一些动作,如通知或者中断。
  • TIME_WAITING:超时等待状态,不同于上一个,可以在指定时间自行返回。
  • TERMINATED:终止状态,线程执行完毕。

OS隐藏JVM中的RUNNABLE和RUNING状态,只看到RUNNABLE状态,将两个状态统称为RUNNABLE运行中。

当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 TIME_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将 Java 线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞) 状态。线程在执行 Runnable 的run()方法之后将会进入到 TERMINATED(终止) 状态。

上下文切换

当前任务在执行完CPU时间片切换到另一个任务之前先保存自己的状态。任务从保存到加载的过程就是一次上下文切换。

上下文切换通常是计算密集型的。需要可观的处理器时间,在每秒上百次的切换中,每次都需要纳秒级别的时间。消耗大量的CPU时间,可能是OS时间消耗最大的操作。

线程死锁

A有资源1,B有资源2。同时想申请对方的资源,相互等待进入死锁状态。

死锁产生的四个条件:

  • 1互斥条件:资源任意时刻只能由一个线程占用。
  • 2请求与保持:一个进程因请求资源阻塞时,对已获得的资源保持不放。
  • 3不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 4循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

如何避免线程死锁?

破坏四个条件中的一个条件即可。

  • 破坏1:没办法,锁本来就是让他们互斥。
  • 破坏2:一次申请所有资源
  • 3:占用资源的线程进一步申请资源时,申请不到可以主动释放自己的资源。
  • 4:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

Sleep和wait区别

  • sleep方法没有释放锁,wait方法释放了锁
  • 都可以暂停线程的执行
  • wait通常被用于线程间交互,sleep用于暂停执行。
  • wait不会自动苏醒,需要被其他激活。sleep会自动苏醒,wait(long timeout)超时后线程会自动苏醒。

start会执行run方法,为什么不直接调用run?

new一个thread,调用start方法, 会启动一个线程并使线程进入就绪状态,分配到时间片就可以开始运行。start会执行线程的响应准备工作,然后自动执行run方法的内容,这是真正的多线程工作。直接执行run,会当成一个main线程下的普通方法执行,并不会在某个线程中执行,所以并不是多线程工作。

锁的升级

目前锁有四种状态:无锁,偏向锁,轻量级锁和重量级锁。锁状态只能升级不能降级。

  • 偏向锁

    大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁是为了在只有一个线程执行同步块式提高性能。

  • 轻量级锁

    几个重要步骤:

    • 复制 Mark Word 到锁记录:拷贝对象头中的 Mark Word 到锁记录中。
    • 更新 Mark Word 指针:拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word。
  • 重量级锁

    在重量级锁的状态下, JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,Monitor 的引用存储在对象头中。

    Monitor 本身是依赖与操作系统的互斥锁(mutex lock)实现的。由于 JVM 线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此这种转换需要耗费很多的 CPU 时间。

二、Synchronized

该关键字解决的是多个线程之间访问资源的同步性,可以保证被他修饰的方法或者代码块在任意时刻只有一个线程执行。在java的早期版本,属于重量级锁,效率低下。为啥呢?

因为监视器锁是依赖底层的OS的Mutex Lock实现,java的线程是映射到OS的原生线程之上的。挂起或者唤醒一个线程需要OS帮忙,OS实现线程之间的切换时需要从用户态转化为内核态,转换时间长,时间成本高。在之后对S关键字进行优化,比如自旋锁,适应性锁等等。效率已经很不错了。

如何使用该关键字

  • 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。

    1
    2
    3
    synchronized void methos(){
    //业务代码
    }
  • 修饰静态类方法:给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前class的锁。静态成员不属于任何一个实例,是类成员,所以如果一个线程A调用一个实例对象的非静态S方法,线程B需要调用这个实例对象所属类的静态S方法是不允许的,不会发生互斥现象。因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁

    1
    2
    3
    synchronized void static method(){
    //业务代码
    }
  • 修饰代码块:

    指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁

    1
    2
    3
    synchronized(this) {
    //业务代码
    }

总结如下:

  • S关键字加到static静态方法和S代码块上都是给class类上锁。
  • S关键字加到实例方法上是给独对象实例上锁。
  • 尽量不使用S(String a),因为在JVM中,字符串常量池具有缓存功能。

双重校验锁实现对象单例(线程安全)–单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @author: Victor
* @date 2021/3/29 18:27
**/
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton(){
}

public static Singleton getUniqueInstance(){
//判断对象有没有实例化,没有实例化才进入加锁代码
if(uniqueInstance == null){
//类对象加锁
synchronized (Singleton.class){
if(uniqueInstance == null){
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

uniqueInstance采用volatile关键字修饰也很必要。uniqueInstance = new Singleton();是分三步执行的:

  • 为u分配内存空间
  • 初始化u
  • 将u指向分配的内存地址。

但是由于JVM具有指令重排的特性,执行顺序可能是1->3->2。在单线程不会有问题,但是多线程会导致一个线程获得还没初始化的实例。使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

构造方法可以使用S关键字修饰吗

不可。构造方法本身就线程安全,不存在同步的构造方法一说。

S关键字的底层原理

JVM层面。

  • S同步语句块的情况

    synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

    执行monitorenter时,线程试图获取对象监视器monitor的持有权(锁)。如果锁的计数器是0表示可以被获取,获取后+1

    执行monitorexit之后,锁计数器清0,释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另一个线程释放为止。

  • S修饰方法

    实现的是ACC_SYNCHRONIZED标识,该表示指明该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

S和volatile的区别

互补而不是对立。

  • V是线程同步的轻量级实现,性能比S好。V用于变量,S修饰方法以及代码块。
  • V保证数据的可见性,不保证数据的原子性。S都能保证。
  • V主要用于解决变量在多个线程之间的可见性,S解决的是多个线程之间访问资源的同步性。

三、ThreadLocal

参考阅读

使用T维护变量时,T为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立的改变自己的副本,而不会影响其他线程所对应的副本。

每个线程中都保存一个ThreadLocalMap的成员变量,,而ThreadLocalMap可以存储以ThreadLocal为 key ,value就是threadlocal 调用set方法设置的值。

内存泄露:

ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key ,value是强引用。如果一个 ThreadLocal 没有外部强引用来引用它,那么系统 GC 的时候,这个 ThreadLocal 的key清理,value不会清理,这样一来,ThreadLocalMap 中就会出现 keynullEntry ,假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。

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

四、线程池

线程池,数据库连接池,http连接池的思想都是为了减少每次获取资源的消耗,提高资源的利用率。

使用线程池的好处:

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。任务到达时,任务可以不需要等待线程创建就能立即执行。
  • 提高线程的可管理性:对线程进行统一的分配,调优和监控。

如何创建线程池

线程池不允许使用Executors创建,通过ThreadPoolExecutor的方式。因为E有以下弊端:

  • FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

通过构造方法实现框架的工具类Executor来实现我们可以创建三种类型的ThreadPoolExecutor。

  • FixedThreadPool : 该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor: 方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  • CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

实现Runnable接口和Callable接口的区别。

R从1.0一直存在,callback在1.5引入,来处理R不支持的用例。R接口不会返回异常,C可以。所以如果任务不需要返回结果或者抛出异常推荐使用R接口,代码简洁。工具类Executors可以实现R和C对象之间的相互转换。

工具类Executors可以实现Runnable对象和callable对象之间的转换。(Executors.callable(Runnable task)或 Executors.callable(Runnable task,Object resule))。

1
2
3
4
5
6
7
@FunctionalInterface
public interface Runnable {
/**
* 被线程执行,没有返回值也无法抛出异常
*/
public abstract void run();
}
1
2
3
4
5
6
7
8
9
@FunctionalInterface
public interface Callable<V> {
/**
* 计算结果,或在无法这样做时抛出异常。
* @return 计算得出的结果
* @throws 如果无法计算结果,则抛出异常
*/
V call() throws Exception;
}

执行execute和submit方法的区别是什么?

  • E提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否。
  • S用于提交需要返回值的任务。线程池返回一个Feature类型类型,可以通过对象判断线程执行成功与否。并且可以通过 Futureget()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

如何创建线程池

线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险

  • 通过构造方法实现

    ThreadPoolExecutor构造方法

  • 通过Executor框架的工具类Executors来实现

    • FixedThreadPool:返回一个固定数量的线程池,线程池的数量始终不变。有新任务检查是否空闲,是则执行,否则等待。
    • SingleThreadPool:返回只有一个线程的线程池。任务保存到队列,按照FIFO的方式执行。
    • CacheThreadPool:返回一个可根据实际情况调整的线程池,线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

ThreadPoolExecutor类分析

ThreadPoolExecutor类提供了四个构造方法。看最长的那个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 用给定的初始参数创建一个新的ThreadPoolExecutor。
*/
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}

三个最重要的参数:

  • corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。
  • maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

其他常见参数:

  • keepAliveTime:当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :executor 创建新线程的时候会用到。
  • handler :饱和策略。

饱和策略

饱和:当前同时运行的线程数量达到最大线程数量并且队列也已经被放满任务时。

  • AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
  • CallerRunsPolicy:调用执行自己的线程运行任务。
  • **DiscardPolicy**:直接丢弃新任务
  • **DiscardOldestPolicy**:直接丢弃最早的未处理的请求。

五、Automic

基本类型,数组类型,引用类型,对象的属性修改类型。

在这里指的是一个操作是不可中断的,即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。所以,原子类就是指具有原子或者原子操作特征的类。并发包java.util.concurrent的原子类都存放在java.util.concurrent.atomic

AtomicInteger是java钟常见的原子类,每种基础类型对应。AI中最重要的就是原子更新操作。

六、AQS

原理

用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器。

AQS的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置成有效的工作线程,并将资源设置成锁定状态。如果资源占用,就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH队列是一个虚拟的双向队列。AQS将每条请求共享资源的线程封装成一个CLH锁队列的一个结点来实现锁的分配。

使用int成员变量来实现同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作来实现值的修改。

AQS对资源的共享方式

  • 独占:
    • 公平锁:排队顺序
    • 非公平锁:抢锁,谁抢到谁用。
  • 共享:多个线程同时执行。

AQS组件总结

  • Semaphore:信号量,允许多个线程同时访问,synchronizedReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
  • CountDownLatch:倒计时器,同步工具类,用来控制线程等待。他可以让某个线程等待直到倒计时结束,再开始执行。
  • CyclicBarrier:循环栅栏。让一组线程到达一个同步点时被阻塞,直到最后一个线程到达屏障,屏障才会开门,所有被屏障拦截的线程才会继续工作。

CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。之前在项目中,有一个使用多线程读取多个文件处理的场景,我用到了 CountDownLatch 。具体场景是下面这样的:

我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。

为此我们定义了一个线程池和 count 为 6 的CountDownLatch对象 。使用线程池处理读取任务,每一个线程处理完之后就将 count-1,调用CountDownLatch对象的 await()方法,直到所有文件读取完之后,才会接着执行后面的逻辑。

  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.

请我喝杯咖啡吧~

支付宝
微信