多线程相关

Java 多线程

导航:

一. 进程和线程的区别

1.1 进程和线程的由来

多线程相关

1.2 进程和线程的特点:

  • 进程:
    • 所有与进程相关的资源都被记录在PCB中
    • 是抢占处理机的调度单位;
  • 线程:
    • 属于某个进程,共享其资源
    • 只由栈堆寄存器、程序计数器和TCB组成

1.3 进程和线程的区别总结

  • 线程不能看做独立应用,而进程可看做独立应用;
  • 进程有独立的地址空间,相互不影响,线程只是进程的不同执行路径
  • 线程没有独立的地址空间,多进程的程序比多线程程序健壮
  • 进程的切换比线程的切换开销大

1.4 它们之间的关系

  • Java对操作系统提供的功能进行封装,包括进程和线程
  • 运行一个程序会产生一个进程,进程包含至少一个线程
  • 每个进程对应一个JVM实例,多个线程共享JVM里的堆
  • Java采用单线程编程模型,程序会自动创建主线程
  • 主线程可以创建子线程,原则上要后于子线程完成执行;

1.5 Java中start和run的区别?

  • 调用start()方法会创建一个新的子线程并启动
  • run()方法只是Thread的一个普通方法的调用

1.6 Thread和Runnable是什么关系?

  • Thread是实现了Runnable接口的类,使得run支持多线程
  • 因类的单一继承原则,推荐多使用Runnable接口;

1.7 如何给run()方法传参?

  1. 构造函数传参
  2. 成员变量传参
  3. 回调函数传参

1.8 如何实现处理线程的返回值?

  • 实现的方式主要有三种
    • 主线程等待法
    • 使用Thread类的join()阻塞当前线程以等待子线程处理完毕
    • 通过Callable接口实现,通过FutureTask Or 线程池获取

1.9 线程的状态

  • 六个状态:
    • 新建(New):创建后尚未启动的线程的状态
    • 运行(Runnable):包含Running和Ready
    • 无限期等待(Waiting):不会被分配CPU执行时间,需要显式被唤醒;
      • 没有设置Timeout参数的Object.wait()方法
      • 没有设置Timeout参数的Thread.join()方法
      • LockSupport.park()方法
    • 限期等待(Timed Waiting):在一定时间后会由系统自动唤醒;
      • Thread.sleep()方法
      • 设置了Timeout参数的Object.wait()方法
      • 设置了Timeout参数的Thread.join()方法
      • LockSupport.parkNanos()方法
      • LockSupport.parkUntil()方法
    • 阻塞(Blocked):等待获取排它锁
    • 结束(Terminated):已终止线程的状态,线程已经结束执行

1.10 sleep和wait的区别?

  • 基本的差别:

    • sleep是Thread类的方法,wait是Object类中定义的方法
    • sleep()方法可以在任何地方使用
    • wait()方法只能在synchronized方法或者synchronized块中使用
  • 最主要的本质区别:

    • Thread.sleep只会让出CPU,不会导致锁行为的改变
    • Object.wait不仅让出CPU,还会释放已经占有的同步资源锁

1.11 锁池和等待池

  • 锁池:

    • 假设线程A已经拥有了某个对象(不是类)的锁,而其他线程B、C想要调用这个对象的某个synchronized方法(或者块),由于B、C线程在进入对象的synchronized方法(或者块)之前必须先获得该对象锁的拥有权,而恰巧该对象的锁目前正被线程A所占用,此时B、C线程就会被阻塞,进入一个地方去等待锁的释放,这个地方便是该对象的锁池
  • 等待池:

    • 假设线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁,同时线程A就进入到了该对象的等待池中,进入到等待池中的线程不会去竞争该对象的锁;

1.12 notify和notifyAll的区别

  • notifyAll会让所有处于等待池的线程全部进入锁池去竞争获取锁的机会
  • notify只会随机选取一个处于等待池中的线程进入锁池去竞争获取锁的机会

1.13 yield

  • 概念:
    • 当调用Thread.yield()函数时,会给线程调度器一个当前线程愿意让出CPU使用的暗示,但是线程调度器可能会忽略这个暗示;

1.14 如何中断线程

  • 已经抛弃的方法

    • 通过stop()方法停止线程 (暴力停止,不安全)
    • 通过suspend()和resume()方法
  • 目前使用的方法:

    • 调用interrupt(),通知线程应该中断了;
      • ①如果线程处于被阻塞装填,那么线程将立即退出被阻塞状态,并抛出一个InterruptedException异常
      • 如果线程处于正常活动状态,那么会将该线程的中断标志设置为true。被设置中断标志的线程将继续正常运行,不受影响;
  • 如何有效得让薪资更上一层楼

    • 增加自己的筹码
      • 尽量打听公司岗位职位的薪酬幅度
      • 感知目标公司的缺人程度,工作的紧急程度;
      • 最有效的方式是已经具备了有竞争力的offer;

二. 多线程与并发-- 原理

2.1 线程安全问题的主要诱因

  • 存在共享数据(也称临界资源)
  • 存在多条线程共同操作这些共享数据

解决问题的根本方法是: 同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作

2.2 互斥锁的特性

  • 互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块(复合操作)进行访问。互斥性也称为操作的原子性。
  • 可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作,从而引起不一致;

synchronized锁的不是代码,锁的都是对象

2.3 根据获取锁的分类: 获取对象锁和获取类锁

  • 获取对象锁的两种用法:
    • 同步代码块(synchronized(this),synchronized(类实例对象)),锁是小括号()中的实例对象。
    • 同步非静态方法(synchronized method),锁是当前对象的实例对象
  • 获取类锁的两种用法:
    • 同步代码块(synchronized(类.class)),锁是小括号()中的类对象(Class对象)。
    • 同步静态方法(synchronized static method),锁是当前对象的类对象(Class对象)。

2.4 对象锁和类锁的总结:

  1. 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块;
  2. 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块中的线程会被阻塞;
  3. 若锁住的是同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程会被阻塞;
  4. 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象同步方法的线程会被阻塞,反之亦然;
  5. 同一个类的不同对象锁互不干扰;
  6. 类锁由于也是一种特殊的对象锁,因此表现和上述1,2,3,4,一致,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁将会是同步的;
  7. 类锁和对象锁互不干扰;

三. synchronized底层实现原理

3.1 实现synchronized的基础

  • Java对象头
  • Monitor

3.2 对象在内存中的布局

  • 对象头
    • Mark World:
      • 默认存储对象的hashCode,分代年龄,锁类型,锁标识位等信息
    • Class Metadata Address
      • 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的数据
  • 实例数据
  • 对齐填充

Monitor: 每个Java对象天生自带了一把看不见的锁

3.3 什么是可重入

  • 从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入;

3.4 关于synchronized

  • 为什么会对synchronized嗤之以鼻

    • 早期版本中,synchronized属于重量级锁,依赖于Mutex Lock实现
    • 线程之间的切换需要从用户态转换到核心态,开销较大;
  • 后来的优化:

    • Java6以后,synchronized性能得到了很大的提升
      • Adaptive Spinning
      • Lock Eliminate
      • Lock Coarsening
      • Lightweight Locking
      • Biased Locking

3.5 自旋锁与自适应自旋锁

  • 自旋锁:

    • 很多情况下,共享数据的锁定状态持续时间很短,切换线程不值得;
    • 通过让线程执行忙循环等待锁的释放,不让出CPU
    • 缺点: 若锁被其他线程长时间占用,会带来许多性能上的开销;
  • 自适应自旋锁:

    • 自旋的次数不再固定
    • 由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定;

3.6 锁消除

  • 更彻底的优化:
    • JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁;

3.7 锁粗化

  • 另一种极端:
    • 通过扩大加锁的范围,避免反复加锁和解锁 [比如在循环内加锁,每执行一次就要加锁和解锁一次,如果放在外面只需要进行一次即可]

3.8 synchronized的四种状态

  • 状态:

    • 无锁、偏向锁、轻量级锁、重量级锁;
  • 锁膨胀方向:

    • 无锁-> 偏向锁-> 轻量级锁-> 重量级锁;

3.9 偏向锁-- 减少同一线程获取锁的代价 CAS(Compare And Swap)

  • 概述:大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得;
  • 核心思想:
    • 如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程Id等于Mark Word 的ThreadId即可,这样就省去了大量有关锁申请的操作;

不适用于锁竞争比较激烈的多线程场合;

3.10 轻量级锁

  • 轻量级锁: 轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
  • 适应的场景:线程交替执行同步块;

若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁;

3.11 锁的内存语义

  • 当线程释放锁时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中;
  • 而当线程获取锁时,Java内存模型会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量;

3.12 偏向锁、轻量级锁、重量级锁的汇总

多线程相关

3.13 synchronized和ReentrantLock的区别?

  • ReentrantLock(再入锁)
    • 位于java.util.concurrent.locks包
    • 和CountDownLatch、FutureTask、Semaphore一样基于AQS实现;
    • 能够实现比synchronized更细粒度的控制,如控制fairness
    • 调用lock()之后,必须调用unlock()释放锁
    • 性能未必比synchronized高,并且也是可重入的;

3.14 ReentrantLock公平性的设置

  • ReentrantLock fairLock=new ReentrantLock(true);
  • 参数为true 时,倾向于将锁赋予等待时间最久的线程
  • 公平锁:获取锁的顺序按先后调用lock方法的顺序(慎用)
  • 非公平锁: 抢占的顺序不一定,看运气
  • synchronized是非公平锁

使用公平锁会造成性能下降,吞吐量降低,如果不是一定需要的话,最好是不用公平锁,一般不用也基本不会有饥饿的情况;

  • 公平锁示例:
    多线程相关

这里new ReentrantLock(true)中参数为true的时候,则整个锁变为公平锁,线程1,线程2 运行的时候回顺序执行,一人执行一次;

3.15 ReentrantLock将锁对象化

  • 判断是否有线程,或者某个特定线程,在排队等待获取锁;
  • 带超时的获取锁的尝试
  • 感知有没有成功获取锁;

3.16 是否能将wait\notify\notifyAll对象化

  • java.util.concurrent.locks.Condition

3.17 synchoronized和ReentrantLock的区别

  • 总结:
    • synchronized是关键字,ReentrantLock是类
    • ReentrantLock可以对获取锁的等待时间进行设置,避免死锁
    • ReentrantLock可以获取各种锁的信息
    • ReentrantLock可以灵活地实现多路通知;

机制:sync操作Mark Word , lock调用Unsafe类的park()方法

四. 多线程的其他知识

4.1 Java内存模型JMM

  • Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
  • 图示
    多线程相关

4.2 JMM

  • JMM中的主内存

    • 存储Java实例对象
    • 包括成员变量、类信息、常量、静态变量等;
    • 属于数据共享的区域,多线程并发操作时会引发线程安全问题;
  • JMM中的工作内存

    • 存储当前方法的所有本地变量信息,本地变量对其他线程不可见;
    • 字节码行号指示器、Native方法信息
    • 属于线程私有数据区域,不存在线程安全问题;

4.3 JMM与Java内存区域划分是不同的概念层次

  • JMM描述的是一组规则,围绕原子性、有序性、可见性展开
  • 相似点:存在共享区域和私有区域;

4.4 主内存与工作内存的数据存储类型以及操作方式归纳

  • 方法里的基本数据类型本地变量将直接存储在工作内存的栈帧结构中
  • 引用类型的本地变量:引用存储在工作内存中,实例存储在主内存中
  • 成员变量、static变量、类信息均会被存储在主内存中
  • 主内存共享的方式是线程各拷贝一份数据到工作内存,操作完成后刷新回主内存

4.5 指令重排序需要满足的条件

  • 在单线程环境下不能改变程序运行的结果
  • 存在数据依赖关系的不允许重排序

无法通过happens-before 原则推导出来的,才能进行指令的重排序;

  • volatile:JMM提供的轻量级同步机制
    • 保证被volatile修饰的共享变量对所有线程总是可见的;
    • 线程不安全;
    • 对其他线程是立即可见的;

4.6 volatile变量为何立即可见?

  • 当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中;
    • 当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效。这样就只能从主内存中重新读取数据了;

4.7 volatile如何禁止重排优化

  • 内存屏障(Memory Barrier)

    • 保证特定操作的执行顺序
    • 保证某些变量的内存可见性
  • 通过插入内存屏障指令禁止在内存屏障前后的指令执行重新排序优化

  • 强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本;

4.8 CAS(Compare and Swap)

  • 一种高效实现线程安全性的方法

    • 支持原子更新操作,适用于计算器,序列发生器等场景
    • 属于乐观锁机制,号称lock-free
    • CAS操作失败时由开发者决定是继续尝试还是执行别的操作; [失败时不会阻塞或者挂起]
  • CAS思想:

    • 包含三个操作数–内存位置(V)、预期原值(A)和新值(B)
  • CAS多数情况下对开发者来说是透明的

    • J.U.C的atomic包提供给了常用的原子性数据类型以及引用、数组等相关原子类型和更新操作工具,是很多线程安全程序的首选;
    • Unsafe类虽提供CAS服务,但因能够操纵任意内存地址读写而有隐患;
    • Java9以后,可以使用Variable Handle API来替代Unsafe
  • 缺点:

    • 若循环时间长,则开销很大
    • 只能保证一个共享变量的原子操作
    • ABA问题 解决: AtomicStampedReference

五. 线程池

5.1 利用Executors创建不同的线程池满足不同场景的需求

  1. newFixedThreadPool(int Threads) 指定工作线程数量的线程池

  2. newCachedThreadPool() 处理大量短时间工作任务的线程池

    1. 试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程
    2. 如果线程闲置的时间超过阀值,则会被终止并移出缓存;
    3. 系统长时间闲置的时候,不会消耗什么资源;
  3. newSingleThreadExecutor() 创建唯一的工作者线程来执行任务,如果线程异常结束,会有另一个线程取代它;

  4. newSingleThreadScheduledExecutor()与newScheduledThreadPool(int corePoolSize)定时或者周期性的工作调度,两者的区别在于单一工作线程还是多个线程;

  5. newWorkStealingPool() 内部会构建ForkJoinPool,利用work-stealing算法,并行地处理任务,不保证处理顺序;

5.2 Fork/Join框架

  • 把大任务分割成若干个小任务并行执行,最终汇总每个小任务结果后得到大任务结果的框架

5.3 为什么要使用线程池?

  • 降低资源消耗
  • 提高线程的可管理性

5.4 ThreadPoolExecutor的构造函数

  • corePoolSize: 核心线程数量
  • maximumPoolSize:线程不够用时能够创建的最大线程数
  • workQueue:任务等待队列
  • keepAliveTime:抢占的顺序不一定,看运气
  • threadFactory:创建新线程,Executors.defaultThreadFactory()

5.5 新任务提交execute执行后的判断

  • 如果运行的线程少于corePoolSize,则创建新线程来处理任务,即使线程池中的其他线程是空闲的;
  • 如果线程池中的线程数量大于等于corePoolSize且小于maximumPoolSize,则只有当workQueue满时才创建新的线程去处理任务;
  • 如果设置的corePoolSize和maximumPoolSize相同,则创建的线程池的大小是固定的,这时如果有新任务提交,若workQueue未满,则将请求放入workQueue中,等待有空闲的线程去从workQueue中取任务并处理;
  • 如果运行的线程数量大于等于maximumPoolSize,这时如果workQueue已经满了,则通过handler所指定的策略来处理任务;

5.6 线程池的状态

  • RUNNING:能接收新提交的任务,并且也能处理阻塞队列中的任务
  • SHUTDOWN:不再接收新提交的任务,但可以处理存量任务;
  • STOP:不再接收新提交的任务,也不处理存量任务;
  • TIDYING:所有的任务都已终止
  • TERMINATED:terminated()方法执行完后进入该状态

5.7 线程池的大小如何选定

  • CPU密集型:线程数=按照核数或者核数+1设定
  • I/O密集型:线程数=CPU核数*(1+平均等待时间/平均工作时间)