多线程

Posted Kirl z

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了多线程相关的知识,希望对你有一定的参考价值。

1. 常见的锁策略

1.1 悲观锁 vs 乐观锁

两个都是设计思想上的概念, 很多地方都有, 不仅限于 java 多线程, 只要符合设计思想的锁实现, 都是其中一种

乐观锁:乐观锁假设认为数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

悲观锁的问题:总是需要竞争锁,进而导致发生线程切换,挂起其他线程;所以性能不高。
乐观锁的问题:并不总是能处理所有问题,所以会引入一定的系统复杂度。

1.2 公平锁 vs 非公平锁

非公平锁: 不保证申请锁的时间顺序 (执行效率更高, 可能有些线程始终无法获得锁)
公平锁: 满足多个线程申请锁的时间顺序, 来获取锁 (执行效率低, 保证每个线程都能执行)

1.3 可重入锁

允许一个线程多次获取同一把锁
JDK所有lock实现和 synchronized 都是可重入锁

1.4 独占锁 vs 共享锁

独占锁: lock 基于AQS 独占锁实现, 同一时间, 只有一个线程获取
共享锁: 同一时间, 多个线程可获取的锁, 并发并行执行

1.5 自旋锁

基于CAS 操作变量时, 如果出现CAS修改失败, 可以使用自旋的方式, 再次尝试: 使用 CAS 再次修改

自旋就是指在 CAS 失败时, 不停地循环执行CAS操作

优缺点:
自旋操作, 本身还是线程运行态执行代码, 会占据一定的系统资源

适用于

  1. 大多数场景下, 在同一个时间, 常常只有一个线程对变量操作(CAS的场景)
  2. CAS 的执行时间, 不能太长, 否则线程冲突的记率就会很大, 进一步导致 CAS 操作失败的线程, 浪费较多的系统资源

2. synchronized 原理

作用: 对对象头加锁的方式, 保证线程安全, 多线程申请同一把锁, 会产生同步互斥的作用

原理:

  1. 该关键字, 会编译为字节码 1 个 monitorenter + 多个 monitorexit指令 (多个的目的,
    是正常执行和遗产执行都要释放的锁)
  2. 以上两个字节码指令, 都是对对象头锁状态的操作, 还基于计数器, 实现了可重入性 (同一个线程, 多次申请同一把锁, 计数器++, 释放一次, 计数器–)
  3. 对对象头加锁, 有偏向锁, 轻量级锁, 重量级锁(从低到高), 三种状态, 只能升级不能降级
  • 重量级锁:大多数情况下, 在同一个时间点, 常常有多个线程竞争同一把锁; 悲观锁的方式: 竞争失败的线程会不停的在阻塞态及被唤醒态之间切换 (用户态和内核态切换), 代价比较大
  • 轻量级锁: 大多数情况下, 在同一个时间点, 常常只有一个线程竞争对象锁
  • 偏向锁: 同一个线程, 重入的方式, 多次申请同一把锁

2.1 其他优化

锁粗化
锁粗化就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成为一个范围更大的锁

public class Test{
    private static StringBuffer sb = new StringBuffer();
    public static void main(String[] args) {
        sb.append("a");
        sb.append("b");
        sb.append("c"); // 多次申请锁, 释放锁, 合并为一个
    }
}

锁消除
锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。

public class Test{
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        sb.append("a").append("b").append("c");
        // 局部变量吗只有当前线程能持有, 其他线程无法获取 (代码逃逸技术), 所以线程是安全的, 就直接不加锁
    }
}

3. 死锁

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

死锁产生的四个必要条件:

  1. 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
  2. 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
  3. 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
  4. 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。

当上述四个条件都成立时, 便形成死锁, 反之, 一个条件被打破, 便可消失死锁

  1. 资源一次性分配(破坏请求与保持条件)
  2. 可剥夺资源:在线程满足条件时,释放掉已占有的资源
  3. 资源有序分配:系统为每类资源赋予一个编号,每个线程按照编号递 请求资源,释放则相反

4. volatile

作用: 修饰在变量上

  1. 保证内存可见性
  2. 建立内存屏障, 禁止指令重排序

原理:

5. 创建线程

java中, 创建线程, 只有一个方式, 必须是 new Thread

定义线程要执行的任务代码方式

  1. 继承 Thread , 重写 run()
  2. 实现 Runnable , 重写 run()
  3. 实现 Callable 接口, 重写 call()

6. CAS

Compare and swap: 比较并交换, 属于乐观锁的一种实现方式, 从java代码层面来看, 属于无锁操作

CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。

CAS可能存在的问题: ABA 问题(时间差)
因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。

解决方法:
解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。在JDK1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。

实现原理:

  • java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
  • unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
  • Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。

unsafe提供的cas操作,又是基于CPU的硬件指令

7. AQS

概念: AbstractQueuedSynchronizer 抽象的队列的同步器

抽象类
队列作为数据结构: 保存线程引用, 线程的同步状态
同步器: 管理线程的同步状态, 来决定哪些线程会阻塞, 有多少线程可以同步执行

独占锁: lock 就是基于 AQS 独占锁的实现, 同一个时间, 只有一个线程获取到锁
共享锁: 同一个时间, 多个线程可以获取到锁, 并发并行的执行(不能超过指定的数量)

8. Lock

Interface Lock

类型方法描述
voidlock() 获得锁。
voidunlock() 释放锁。

作用和synchronized加锁是一样的

语法:

synchronized (object) {

}

ReentrantLock lock = new ReentrantLock();

lock.lock();
try {

} finally {
	lock.unlock;
}

lock 加锁释放锁操作, 相比synchronized 更为灵活
表现: 对死锁的解决, lock更好处理
还提供了超时等待获取锁, 可中断获取锁, 等更多的获取锁的方式

实现原理:
AQS + CAS + 自旋

提供了多种锁:

  1. 公平锁和非公平锁
  2. 可重入锁
  3. 读写锁

8.1 ReentrantReadWriteLock 读写锁

作用: 提供两把锁(读锁, 写锁), 满足一些读多写少的场景, volatile读保证线程安全, 加锁保证写的线程安全, 读读并发, 读写/写写互斥

读锁: 基于 AQS 共享锁的模板
写锁: 基于 AQS 独占锁

8.1 Lock 和 synchronized 对比

Lock 和 synchronized 对比
(1)概念: 都是用于加锁保证线程安全的手段, 只是 Lock 提供所对象, 而 synchronized 是基于对象头来实现加锁的
(2)语法: lock显示的加锁, 释放锁; synchronized 是内建锁 (JVM内部构建的锁), 隐式的加锁释放锁
lock 使用上更灵活
(3)提供的功能: lock提供了多种获取锁的方式 (获取锁, 非阻塞式的获取锁, 可中断的获取锁, 超时获取锁)
synchronized只有一种方式: 竞争锁(竞争失败, 阻塞)
(4)性能: 同一个时间点, 竞争锁的线程数量越多, synchronized 性能下降得越快(竞争失败的线程, 不停地在阻塞态与被唤醒态之间切换, 用户态与内核态之间切换), 使用lock 性能更好

9. 线程安全的Map

因为 HashMap 是线程不安全的, 在多线程环境下, 要使用线程安全的数据结构

9.1 HashMap

(1) 特点: 存放键值对的数据, 键唯一(不重复), 无需存放 (值)

(2) 实现:
1.7: 数组 + 链表
1.8: 数组 + 链表 + 红黑树

(3) put 流程
put(K key, V value)

  1. 通过键(对象)的 hashcode 计算 hash 值 (数组索引) 找到数组索引位置, 存放元素
  2. 如果没有元素, 创建一个节点 (保存键值数据), 再放到数组索引位置上
  3. 有元素 (hashcode 相同), 如果equals 相同 (表示 key 有相同), 替换值
    1.7: 链表头插
    1.8: 如果是红黑树, 树节点, 链表, 尾插(在一定情况下链表转红黑树)

9.2 HashTable

1.7 / 1.8 都是基于 数组 + 链表
都是基于synchronized 使用在方法上, 进行加锁
作用:

  1. 保证线程安全
  2. 性能低下 (所有线程调用同一个HashTable 对象的同步方法, 都是使用同一个锁, 都是同步互斥)

9.3 ConcurrentHashMap

满足高并发使用场景的Map结构

(1) 底层数据结构
数组 + 链表
数组 + 链表 + 红黑树

(2) 高并发体现一
不论1.7还是1.8, 读操作都是无锁的, 基于final修饰键, volatile修饰值, 保证小城安全的读读/ 读写并发, 写写互斥 (写都是加锁)

(3) 高并发体现二
对写操作: 加锁保证线程安全, 但是基于加锁细粒度化提高效率

1.7: 分段锁加锁 (一个分段多个节点加锁), 加锁的方式是基于 ReentrantLock (Segment 分段继承了 ReentrantLock)

存在缺陷: hash 冲突严重的时候, 查询效率下降

1.8:基于一个节点来加锁, 相对于1.7的实现来说, 加锁的粒度化更细, 性能更高, put 存放元素时, 根据键计算hash值(数组索引)

如果该索引位置没有元素, 使用 CAS 的方式插入新节点
如果有元素, 使用 synchronized 锁住该元素, 再进行保存(写操作)

以上是关于多线程的主要内容,如果未能解决你的问题,请参考以下文章

线程学习知识点总结

多个请求是多线程吗

python小白学习记录 多线程爬取ts片段

多线程编程

多线程编程

python多线程