线程基础知识
线程安全?
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。
因为我们为了能够充分利用硬件资源, 所以会采用多线程处理模型, 这样就会导致资源争抢问题, 导致数据的不一致问题
由于多个线程同时对该值进行访问和修改导致了 数据的不一致, 我们就可以限制线程的访问, 可以采用互斥同步的方式实现
当然也可以不使用 互斥同步来实现 —- CAS
死锁
什么叫死锁 : 一组互相竞争资源的线程, 因为互相等待而导致永久阻塞的现象.
死锁有四个条件
- 互斥条件 : 当前资源 只能被一个线程获取
- 占有且等待 : 线程等待过程中不会释放已有的资源
- 不可抢占 : 一个线程已经占有的资源, 在释放之前, 不能被其他线程抢占
- 循环等待 : 多个线程互相等待对方释放资源, 形成一个循环状态
只需要打破其中一个条件即可:
- 破坏循环等待条件: 采用资源有序分配其基本思想是将系统中的所有资源顺序编号,将紧缺的,稀少的采用较大的编号,在申请资源时必须按照编号的顺序进行,一个进程只有获得较小编号的进程才能申请较大编号的进程。
- 破坏占有且等待条件 : 一个线程在开始时就申请它所需要的所有的全部资源
- 破坏不可剥夺条件 : 一个进程不能获得所需要的全部资源时便处于等待状态,等待期间他占有的资源将被隐式的释放重新加入到 系统的资源列表中,可以被其他的进程使用,而等待的进程只有重新获得自己原有的资源以及新申请的资源才可以重新启动,执行。
notify 和 notifyAll 的区别
首先, 每个对象都有两个池子, 一个叫锁池, 一个叫等待池
- 锁池 : 如果线程 A 已经拥有了某个对象的锁, 而其他的线程想要调用这个对象的某个 synchronized 方法 或者 synchronized 代码块 时, 由于这些线程在进入对象的 synchronized 方法之前必须先获得对象的锁的拥有权, 但是该对象的锁目前正在被线程 A 拥有, 所以这些线程就会进入该对象的锁池 等待竞争锁
- 等待池 : 假设一个线程A, 获取到锁后 调用 wait 方法, 就会释放掉锁, 并进行 等待池中, 不再参与 锁的争抢
notify 只会从等待池中唤醒一个线程 并移动到 锁池中,等待竞争锁 , 而 notifyAll 则是将等待池中的线程全部移动到锁池中, 然后参与锁的竞争
锁
volatile 原理
Java中通过 volatile 实现共享变量在多个线程之间的可见性(也就是每个线程对于volatile修饰的共享变量总是能读取到最新的值) , 以及禁止CPU优化执行导致指令的乱序执行, 也就是对应到 并发编程的三大特性之中的 可见性 和 有序性
在Java层面, 之所以能够对此进行实现是由于 java 的 JMM 抽象模型, (JMM 抽象模型 将CPU的高级缓存进行抽象为 工作内存 和 本地内存, 每个线程都有一个工作内存) 一个线程对于一个变量的操作,需要将该变量从 主内存中加载到 工作内存中才能进行操作, 最后再写入到主内存中
JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
JMM 有通过 happens - before 原则规定了多线程之间的内存可见性, 如果一个线程对于一个变量的更新操作能再另一个线程中随时看到最新的值, 那么这两个操作之间就存在有 happens-before 关系 (如果一个操作发生在另一个操作之前, 那么第一个操作的执行结果将对第二个操作可间, 并且第一个操作的执行顺序排在第二个操作之前)
- happens-before原则
- as-if-serial : 单线程下必须保证执行结果一致
- 加锁解锁
- volatile
- 传递规则
- 线程启动
- 线程终结
- 对象终结
- 线程中断
对于 Java 编译而言, 只是在字节码中变量的修饰符上添加了 volatile标记而已,
有序性
- 对于 jvm 层面, 针对 volatile 修饰的变量的则是最终调用到 OrderAccess 类中的 load_acquire() , 和 release_store() , 而这两个方法都是通过在该引用上 加上 volatile 标记实现的, 所以最终还是借助了 C 语言层面的 volatile
- C 语言层级的 volatile 其实也就是禁止了编译器对于该变量相关操作进行优化
- CPU架构不同会有不同的乱序情况发生, 常见的 服务器CPU架构以及日常使用的计算机CPU架构都是TSO(全局有序)模型
- jvm为了保证通用性, 在 volatile 变量的读写操作前后都进行添加内存屏障
- 虽然 C 语言层面的 volatile 会保证编译器不做优化, 但是并不能保证CPU在执行的时候不会产生乱序执行, 所以需要通过添加内存屏障的方式保证不会发生不期望的乱序
可见性
- 强制每次都从内存中进行读取新值, 而关于值的更新也进行强制的刷如内存, CPU底层则是通过 store buffer 和缓存一致性协议完成的
CAS
- compare and set 或 compare and swap 比较并交换
- 可以有 Java 中的 unsafe 类完成该方法的调用, 需要传递参数 对象, 地址偏移, 期待值, 更新值
- 主要做的操作就是, 对对应位置的值进行比较, 如果期待值和内存中的对应位置的值相等, 就将其更新为 更新值
- 由于 该过程是分为多个步骤的, 所以为了实现该指令的原子性, 其实是由底层 CPU 提供的指令 lock cmpxchg
- 通过 CAS 可以将该过程满足原子性
Synchronized
用户态和内核态
早期JDK对于 sync 是直接通过重量级锁实现的, 并没有锁升级的过程, 导致部分场景下很消耗性能, 因为重量锁是通过 操作系统级别 的系统调用 进入内核态进行处理的, 所以很消耗性能
早期的软件呢, 可以访问一切的指令而不受限制, 但是带来一些问题, 因为软件其实是无法完全信任的, 有可能由于bug 或者 恶意的代码将操作系统和一些资源进行损坏, 所以后来为了系统的稳定性和健壮性, 就将指令分等级, ring 0 - 3 共 4 个等级, Linux 实现中, 将一些设计到系统管理, 资源分配等重要的指令 定为 0 级, 只有内核才能进行调用, 而可以任意由应用程序调用的指令, 再ring3级, 不受限制, 当需要调用执行ring0级的指令时, 需要通过内核的同意和授权, 切换上下文(内核空间具有独立的地址空间, 通用和专用的寄存器组) . 最后交由内核代替执行.
进入内核态执行将会保留当前的上下文信息, 保留一些寄存器的信息, 方便执行完后回到当前线程中之后能正常执行下去
所以在切换过程中, 需要很繁琐的操作, 比较影响性能, 上下文切换是一个 CPU 密集型的操作, 即使是现在的操作系统, 在进行上下文操作的时间里, 也是可以执行上千条指令了
锁的升级过程
- 无锁 -> 偏向锁
- 大部分使用的锁对象的锁争用其实都一个线程在使用, 获取, 所以上锁是消耗性能的,
- 所以优先会上偏向锁, 偏向锁的上锁过程是 使用 CAS 将 mark word 中 添加 线程 ID, 并修改锁的标志位
- 这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作。
- 偏向锁 -> 轻量级锁
- 当存在锁竞争的时候, 那么偏向锁就会失效, 此时就会进行锁膨胀, 升级为轻量级锁
- 升级为轻量级锁后, 对应锁的记录 mark word 也会有所改变
- 对上锁的线程, 会在其线程栈中, 生成一个 lock record, 将原本的mark word记录于锁记录中. 将锁记录的 owner 指针指向 锁对象, 最后将 mark word 中的部分位用于记录指针指向,指向线程栈中的 lock record,
- 在 jdk 1.6 之前, 轻量级锁是经过固定的自旋次数仍然没有获取到锁则将锁升级为重量级锁, (默认情况下是自旋 10 次, 当然该次数是可以通过参数进行设置的) 但是自旋锁会会占用 CPU, 如果是能够快速结束任务, 快速获取到锁, 那么消耗是值得的, 但是 如果大量线程争抢锁, 或者临界区执行比较耗时, 那么就会导致CPU性能被消耗太多
- jdk 1.6 之后, 推出了自适应自旋, 动态的决定自旋的次数, 比如:
假如一个线程1刚刚成功获得一个锁,当它把锁释放了之后,线程2获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放该锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程1自旋的次数。
- 轻量级锁也称为 乐观锁, 因为该线程并没有将线程进行挂起, 而是让线程空循环等待
- 轻量级锁 -> 重量级锁
- 当竞争激烈时, 或者自旋次数超过一定数量时, 就是将锁升级为重量级锁
- 重量级锁依赖的对象内部的monitor锁, 而 monitor 又是依赖于操作系统的 互斥锁 来实现的
- 锁升级后
- 更改锁标志位
- markword 中添加指向 互斥锁的 指针
- 为什么重量级锁消耗大呢?
- 因为当线程检测到锁是重量级锁后, 会把想要获得锁的线程阻塞, 阻塞线程是不消耗 CPU 的, 但是状态的切换是需要内核完成, 很消耗时间, 有可能比执行业务代码的时间还要长
相关问题
- 轻量级锁什么时候升级为重量级锁 ?
- 竞争加剧的时候, 在 jdk 1.6 之前, 如果轻量级锁 其余线程在自旋超过10次后, 就会将 锁升级为重量级锁
- 在 jdk 1.6 之后, JVM有相应的自适应算法, 自旋的次数通过算法确定, 如果相应次数后都没有获取到锁, 则会将 轻量级锁升级为重量级锁]
- 为何有自旋锁 还需要重量级锁 ? (轻量级锁和偏向锁都是用户空间的)
- 重量级锁 ObjectMointer 内部存在队列 等待队列, 所有拿不到锁的放入等待队列, 不用占用CPU时间
- 偏向锁是不是一定比自旋锁效率高
- 不一定, 如果 很多线程争抢锁, 就会导致 锁撤销的过程, 消耗性能
- 所以明知道会有很多线程争抢同一吧锁时, 我们可以直接关闭掉 偏向锁
- 例如在 JVM启动时, 会启动大量线程对资源进行加载, 此过程是会争抢锁的, 所以在开始时可以关闭掉偏向锁, 而在启动完成之后 再进行偏向锁的开启
- 可以通过JVM参数进行设置 延时开启 偏向锁/
- 匿名偏向
- 在偏向锁功能开启之后, 会造成对象在创建时, 就已经被添加了偏向锁, 但是在 markword 中并没有记录线程指针, 所以此时是匿名偏向状态
- 偏向锁直接进入 重量级锁的状态过程
- 如果在偏向锁过程中调用 wait 方法…. , 因为在轻量级锁状态无法调用 wait, 所以会直接升级为重量级锁
reentrantLock 和 synchronized 的区别, 分别适合的应用场景
- sync 是Java中的一个关键字, 对应编译生成 monitor enter 和 monitor exit 进行上锁和解锁, 并且支持重入
- reentrantLock 则是基于 AQS的阻塞队列的线程维护机制(state变量维护重入锁, 双向链表来实现线程的排队策略)
- sync 是非公平的, 而 reentrantLock 根据不同的同步器实现可以实现不同的公平性
- sync 是自动加锁, 自动释放锁的, 而 reentrantLock 则是需要 手动加锁, 手动释放锁
- lock相对更加灵活, 可以有响应中断的 lockInterruptibly(), tryLock() 尝试获取锁
- reentrantLock 还支持绑定多个 condition , 用来精确唤醒, sync 则要么唤醒一个, 要么唤醒全部
AQS
AQS主要有以下几个部分组成
state 变量
AQS中通过定义了状态变量 state, 他有以下两种使用方法:
- 互斥锁:
当 AQS 只实现互斥锁的时候, 每次只要原子更新 state 的值从 0 变为 1 ,就成功获取到了锁, 可重入是通过不断不断对 state 原子更新加 1 实现.
互斥锁是一种独占锁,每次只允许一个线程独占,且当一个线程独占时,其它线程将无法再获取互斥锁及共享锁,但是它自己可以获取共享锁。 - 共享锁:
当AQS需要同时实现为互斥锁+共享锁的时候,低16位存储互斥锁的状态,高16位存储共享锁的状态, 主要用于实现读写锁。
共享锁同时允许多个线程占有,只要有一个线程占有了共享锁,所有线程(包括自己)都将无法再获取 互斥锁,但是可以获取共享锁。
AQS队列
AQS内部维护了一个队列, 当线程 lock() 获取锁失败的时候 (并非是 tryLock()) 都将进入该队列中进行排队, 等待获取锁的线程释放掉锁后, 唤醒后续的线程
Condition队列
AQS中有一个内部类 ConditionObject, 它实现了 Condition 接口, 主要用于实现条件锁
在 ConditionObject 中 也维护了一个队列, 这个队列主要用于等待条件的成立, 当条件成立后, 其他线程将调用 signal , 将对应队列中的元素移动到 AQS 队列中, 等待占有锁的线程释放锁后被唤醒
典型使用场景就是 BlocingQueue 中的实现, 当队列为空时, 将其队列中的元素移动的到 AQS 队列中等待被唤醒
模板方法
运用模板方法模式, 巧妙的进行了设计, 上锁 和 唤醒的步骤通过模板方法已经设置好, 留下钩子函数, 交由 子类进行实现, 有子类确定是否应该上锁, 解锁
高并发容器
ConcurrentHashMap的实现
concurrenthashmap 不支持 null key 和 null value
JDK1.7
JDK 1.7 之前, concurrentHashMap 是通过对数组进行分段, 其实是用一个 segment 代表以前的一个位置, segment 又是一个 entry 数组, 用于具体存放元素, 并且 segment 继承自 reentrantLock, 当对 segment 数组进行增删时, 会锁定对应的段, 而不是将整个容器进行上锁, 这样提高了并发性
其中的每一个segment都类似于一个 hashmap, segment的个数一旦初始化就不能进行扩张,但是可以对 segment 本身 hashEntry 进行扩容, 而segment的个数可以通过实例化时通过 传参传入, 默认是16个, 参数对应其中的支持并发等级(concurrency_level),
put操作
每个segment的位置最初都是 null , 不进行初始化, 当插入元素时, 才会进行初始化,初始化时通过 CAS 和 双重检查机制保证只有一个线程成功初始化 segment. 当获取到 segment 后, 调用 segment 的put操作, 在segment#put中, 因为segment继承了 ReentrantLock 所以使用当前segment进行 tryLock() 尝试获得锁,
如果获取到锁:
- 遍历当前位置的已有元素, 主要是为了找寻是否有相同的key值, 如果已经有了, 直接进行替换
- 没有值就直接新建放入
- 如果该位置有 hashEntry 但是没有相同key ,使用头插法插入
如果没有获取到锁: 会调用 scanAndLockForPut
该方法做的事儿,不断自旋的 tryLock() —- > (tryLock方法会在没有获取锁的时候立刻返回false, 而不是进行阻塞获取, 当能获取锁就直接获取锁并返回 true) , 在tryLock过程中, 会去查表, 判断是否已经有该 key 了, 如果没有就新建, 然后到达一定次数就直接调用 lock() 阻塞获取锁, 并最终将新创建或者是找到的node直接返回 调用插入逻辑
rehash
concurrentHashMap扩容只会扩容到以前的两倍, 老数组中元素位置要么不变, 要么变成 index + oldsize, 通过头插法放入新的数组中去
JDK1.8
而 1.8 之后, 取消了 segment分段锁, 而是采用 node数组 + 链表/红黑树实现
采用 CAS 和 sync 来保证并发安全, 数据结构和hashmap 1.8 类似 使用 数组 + 链表/红黑树 实现, sync 只锁定当前链表或红黑树二叉树的节点, 这样只要hash不冲突, 就不会产生并发修改问题
一个变量sizeCtl用于表示调整表大小和初始化表时表的状态,
- 当sizeCtl是负数时, -1 表示表正在初始化, 否则 -(1 + 活动调整大小的线程数)
- 当表为 null 时, 表示 表要初始化的大小
- 表初始化后, 保存下一个元素的计数值, 根据数值调整表的大小 —- 表的大小
Put操作
如果表为 null 则 调用initTable进行表的初始化, 初始化表会调用 CAS 将 sizeCtl 将 值置 -1 ,成功就初始化表, 没成功的就调用 Thread.yield() 暂时让出CPU, 直到表初始胡成功后跳出循环, 继续执行 putVal操作
有几种情况:
- 对应桶内为null ,使用 CAS 设置进去
- 如果是树 / 链表 则使用 synchronized 锁住头节点
- 还有一种特殊情况, 节点记录的hash状态 = -1 则需要进行扩容操作
ThreadLocal
什么是ThreadLocal
threadlocal是为了各个线程具有自己线程本地的变量,做到变量的线程隔离机制, 可以理解为为每个线程绑定自己的专属值, 避免线程安全问题
实现
其实是在Thread类中, 有两个ThreadLocalMap 的成员变量, 一个 threadLocals 另一个 inheritebleThreadLocals , 即 ThreadLocal 只是对 一个类似hashMap的ThreadLocalMap 的一个封装, 真正存放数据的是 ThreadLocalMaps
关键方法
get
get 方法首先要获取到线程对象,然后通过该线程对象获取到 线程的成员变量 threadLocals (ThreadLocalMap 类型), 该Map就类似于 hashMap, 通过一个Entry数组保存键值对, Entry 中 key 是一个ThreadLocal的弱引用 指向(weakReference)
弱引用问题
为什么需要使用弱引用呢?
因为 threadLocal 是一个对象的实例, 而一个对象的实例很可能在某个方法执行结束后变没用了,会被回收. 如果使用强引用指向的话, 该实例对象被回收了, 意味着指向的 ThreadLocal 也是应该被回收的, 但是由于强引用的指向,导致无法进行回收, 而使用 弱引用进行指向, 则会直接进行回收
但是还是可能会存在内存泄漏的问题, 因为Entry数组中的 value 值仍然是被强引用指向的, 所以如果 TL 被回收掉, 就无法找到 value , 但是value 还是存在, 就导致了 内存泄漏, 不过在 TL 进行 get , set 时, 都会通过开放地址法顺序查找, 发现 key == null 就会移除已经没用的 value 值, 不过在开发过程中还是尽量通过手动调用 remove 方法移除, 避免影响性能以及导致内存泄漏
引用的几种类型?
强 , 软 , 弱 , 虚
强: 只要指向, 就不进行回收
软: 在内存不足时 才会进行回收
弱: 如果只有弱引用指向, 则直接进行回收
虚: 是指向堆外内存的引用, 由于堆外内存 JVM 无法直接进行管理
GC 判定垃圾
根可达算法: 哪些算根对象?
- 常量指向
- 静态成员指向 : 某个类静态变量能够引用的对象
- 本地方法 JNI 需要使用
- 线程栈: 栈帧中本地变量表
线程池
为何使用线程池
池化技术的主要思想就是为了减少每次获取资源的消耗, 提高资源的利用率,
好处:
- 降低资源消耗 : 通过复用已经创建的线程降低线程创建和销毁造成的消耗
- 提高相应速度: 当任务到达时, 不需要等待创建线程的过程就能立即执行
- 提高线程的可管理性 : 线程是稀缺资源, 如果无限制的创建, 不仅会消耗系统资源, 也可能会降低系统的稳定性, 使用线程池可以统一分配, 调优和监控
execute 和 submit 的区别
execute 是由 顶级接口 Executor 提供的, 用于执行不需要返回值的 Runnable 任务, 而 submit 则是执行需要返回值的任务, 如果传递的是 Runnable 子类, 则会通过 newTaskFor 将 runnable 子类包装为 RunnableFuture 接口下的 FutureTask 对象
线程池的创建方式
Executors创建
禁止使用 Executors 进行创建, 因为这是一个工厂类, 对创建的参数传递进行便理的处理, 而我们开发过程中, 应该根据实际场景进行各个参数的选择:
Executors 创建的线程池弊端:
- SingleThreadPool 和 FixedThreadPool : 允许任务的队列长度为 Integer.MAX_VALUE, 可能会导致堆积大量的任务, 而导致 OOM 异常
- CachedThreadPool 和 SecheduledThreadPool : 允许创建的线程数为 Integer.MAX_VALUE, 可能会导致创建大量的线程,导致性能消耗, 更甚至 OOM 异常
ThreadPoolExecutor
七个参数
- 核心线程数
- 最大线程数
- 非核心线程可以空闲等待的时间: 超过该时间, 则关闭线程
- TimeUnit: 时间单位
- 任务队列 : 该队列只保存 execute 提交的任务
- 线程工厂: 创建新贤臣时使用的工厂
- handler : 拒绝策略, 由于达到线程边界 和 队列容量边界 时对新任务的处理
过程:
当一个任务通过 execute 提交到 ThreadPoolExecutor 中时, 会首先 线程池中的线程数目是否已经达到了 核心线程数, 如果没有达到, 则会创建一个核心线程, 并将该任务教给他执行, 如果核心线程数已经达到, 则放入队列中, 如果队列已满, 则判断是否达到最大线程数, 没有达到的话, 就会创建新的线程执行, 如果已经达到最大线程数, 那么就会执行 拒绝策略
拒绝策略
- abortPolicy: 抛出异常
- callerRunsPolicy : 调用当前线程执行任务 (也就是调用 execute 方法的线程) , 如果程序已经关闭, 则会丢弃掉该任务
- DiscardPolicy : 直接忽略掉
- DiscardOldestPolicy : 丢弃最早未处理的任务请求
上下文切换:
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
任务队列
线程池模型 | 任务队列 |
---|---|
FixedThreadPool | LinkedBlockingQueue |
SingleThreadExecutor | LinkedBlockingQueue |
CachedThreadPool | SynchronousQueue |
ScheduledThreadPool | DelayedWorkQueue |
blocking queue 家族:标签