多线程高阶

本文深入探讨了Java并发编程中的锁策略,包括乐观锁、悲观锁、读写锁、自旋锁和可重入锁的概念及其应用场景。讲解了CAS(Compare and Swap)操作的工作原理和ABA问题,以及JDK中使用CAS实现的相关API。同时,介绍了synchronized的优化方案,如锁粗化和锁消除,并讨论了死锁的原因、条件和解决方案。最后,简要概述了Lock接口、ReentrantLock、Semaphore和CountDownLatch等并发工具类,以及ThreadLocal的使用和原理。
摘要由CSDN通过智能技术生成

常见的锁策略

1. 乐观锁和悲观锁

  1. 乐观锁
  • 乐观锁假设认为数据一般不会产生并发冲突, 所以在数据进行提交的时候, 才会正式对数据是否产生并发冲突进行检测, 如果发生并发冲突了, 就返回错误信息, 让程序猿决定怎么去做.
  • 也就是: 保持乐观心态, 认为在大多数情况下, 同一个时间点, 只有一个线程执行修改操作.
  • 缺点: 并不总是能解决所有问题, 所以会引入一定的系统复杂度
  • 乐观锁一般适用于写比较少的场景(多读场景)
  • 乐观锁一般是使用版本号机制或者CAS算法实现
  1. 悲观锁
  • 悲观锁是总是设想最坏的情况, 每次去拿数据的时候都认为别人会修改, 所以在每次拿数据的时候都会上锁, 这样别人在拿这个数据的时候都会阻塞等待知道他拿到锁(共享资源每次只给一个线程使用, 其他线程阻塞等待, 用完之后再把资源转让给其他线程)
  • 也就是: 保持悲观心态, 总是认为多个线程同一个时间点执行修改操作
  • 缺点: 总是需要竞争锁, 进而导致线程切换, 挂起其他线程, 性能不高
  • 悲观锁一般适用于多写的场景
  • Java中synchronized和ReentrantLock等独占锁就是悲观锁的实现

2. 读写锁

读写锁简介
  • 为了解决多线程安全问题, 我们几乎会高频率的使用到独占式锁, 通常是使用java提供的关键字synchronized或者concurrent包中实现了Lock接口的ReentrantLock(可重入互斥锁), 他们都是独占式获取锁,也就是在同一时刻只有一个线程能获取到锁; 但是在大部分场景下, 只是读数据, 写数据很少, 这样就会出现性能瓶颈; 针对这种读多写少的情况, java还提供了另外一个实现了Lock接口的ReentrantReadWriteLock(读写锁).读写锁允许同一时刻被多个线程访问, 但是在写线程访问的时候, 所有的读线程和其他的写线程都会被阻塞.
  • 适合的场景: 读读并发执行, 读写/写写互斥. 在读写分离的场景下效率更高(写锁能够降级为读锁)
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class FileReadWrite {

    public static void main(String[] args) {
        ReadWriteLock lock = new ReentrantReadWriteLock();
        Lock readLock = lock.readLock();
        Lock writeLock = lock.writeLock();
        // 20个线程对同一个文件进行读操作
        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                readLock.lock();
                try {
                    // 文件读操作
                } finally {
                    readLock.unlock();
                }
            }).start();
        }
        // 20个线程对同一个文件进行写操作
        for (int i = 0; i < 20; i++) {
            writeLock.lock();
            new Thread(() -> {
                try {
                    // 文件写操作
                } finally {
                    writeLock.unlock();
                }
            }).start();
        }
    }
}

3. 自旋锁

  • 实际情况下, 线程在抢锁失败后, 过不了多久, 锁就会被重新释放.
  • 自旋锁就是获取不到锁的线程不会立即阻塞等待, 而是通过循环的方式不断的尝试去获取锁(获取不到锁, 就死等)
  • 可以理解为自旋锁就是下面这行代码
while(抢锁(lock) == 失败){}
  • 自旋锁的缺点: 如果锁在被抢后没有很快释放, 那么自旋锁的线程就是长期在做无用功, 消耗CPU资源

4.可重入锁

  • 可重入锁就是: 可以进入的锁, 即允许同一个线程多次获取同一把锁
  • 比如在递归中, 一个递归函数里有加锁操作, 递归过程中这个锁不会阻塞自己, 那么这个锁就是可重入锁(因为这个原因可重入锁也叫递归锁)
  • Java中只要是以Reentrant开头命名的都是可重入锁, JDK提供的所有现成的Lock实现类, 包括synchronized关键字锁都是可重入锁.

CAS

1. 什么是CAS

  1. CAS:Compare andswap,比较并交换
  2. 一个CAS涉及以下操作:
  • 假设内存中有原数据V, 有旧的预期值A, 以及需要修改的新值B
  • 第一步: 比较V和A的值是否相等(比较)
  • 第二步: 如果比较相等, 那么把B的值写入V(交换)
  • 返回boolean类型值, 表示操作是否成功
    在这里插入图片描述
  • 当多个线程同时对某个资源进行CAS操作的时候, 只能有一个线程操作成功, 但是并不会阻塞其他线程(每个线程都是运行态), 其他线程只会受到操作失败的信号(只有一个操作成功, V的值已经被修改, 再判断A是否等于V就会返回false)
  • 可见, CAS其实是一个乐观锁

2. CAS的底层实现

  • 针对不同的操作系统, JVM用到了不同的CAS实现原理
  • 简单来说就是CPU+操作系统+JDK的一个unsafe类

3. 对于CAS中的ABA问题

  • ABA问题就是一个值从A变成了B, 又从B变成了A, 而这个期间我们不知道这个过程.
  • 线程执行第一步和第三步之间, 有其他的线程修改了主内存的变量值A->B->A
  • 解决方案: 引入版本号(例如携带AtomicStampedReference之类的时间戳作为版本信息)

4. JDK中使用CAS实现的API

  • java.util.concurrent.atomic: 原子性的并发包(之下的很多API几乎都使用了CAS实现)
    在这里插入图片描述

  • AtomicInteger, AtomicBoolean, AtomicLong…

  • 实现原理:
    CAS + 自旋
    在这里插入图片描述

    再谈synchronized

1. monitor机制

编译为字节码文件时, 会生成多个monitorenter + 多个monitorexit指令, 计数器会进行重入的计数.

2. 对象头的锁机制

在这里插入图片描述

3. synchronized的优化方案

(1) 根据不同的场景使用不同的加锁方式

JVM将synchronized锁分为无锁, 偏向锁, 轻量级锁, 重量级锁状态. 会根据情况, 进行依次升级.

  1. 无锁: 没有对资源进行锁定, 所有的线程都能访问并修改同一个资源, 但是同时只有一个线程能修改成功, 其他失败的线程会不断重试知道修改成功.
  2. 偏向锁: 对象的代码一直被同一个线程执行, 不存在多个线程竞争, 该线程在后续的执行过程中自动获取锁, 降低获取锁所带来的性能开销. 偏向锁其实就是指: 偏向第一个加锁线程, 该线程是不会主动释放偏向锁的, 只有当其他线程尝试竞争偏向锁才会被释放
    偏向锁的撤销, 需要在某个时间点上没有字节码文件执行的时候, 先暂停拥有偏向锁的线程, 然后判断然后判断锁对象是否处于被锁定的状态. 如果线程不处于活动状态, 就将对象头设置为无锁状态, 并撤销偏向锁; 如果线程处于活动状态, 就升级为轻量级锁的状态.
  • 使用场景:重入操作: 同一个线程已经持有某个对象锁, 下一次再次申请该对象锁.
  1. 轻量级锁: 轻量级锁就是当锁是偏向锁的时候, 被第二个线程访问, 此时偏向锁就会升级为轻量级锁, 第二个线程会通过自旋的形式尝试获取锁, 线程不会阻塞, 从而提高性能.
    当线程自旋超过一定次数时, 轻量级锁会升级为重量级锁; 当一个线程持有锁, 另一个线程在自旋尝试获取锁, 而此时又有第三个线程来访时, 轻量级锁也会升级为重量级锁.
  • 使用场景: 同一个时间点, 只有另外一个线程来竞争同一个对象锁
  • 实现原理: CAS
  1. 重量级锁:指当有一个线程获取到锁之后, 其余所有等待获取该锁的线程都会处于阻塞状态.
  • 使用场景: 同一个时间点, 多个线程竞争同一个对象锁
  • 特性: 会阻塞线程, 竞争失败的线程会在阻塞态以及被唤醒的状态切换, 极消耗性能
  • 原理: 重量级锁通过对象内部的monitor监视器实现, 而monitor的本质是依赖于底层操作系统的Mutex Lock实现

按照开销/代价高低: 无锁, 偏向锁, 轻量级锁, 重量级锁 —— 锁只能升级不能降级.

(2) 锁粗化

多次加锁解锁操作合并为一次

(3) 锁消除

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

死锁

导致死锁的原因

多个线程同时执行, 当两个线程互相持有对方所需要的资源, 又不主动释放资源, 导致所有线程都无法继续执行, 导致线程进入无尽的阻塞, 造成死锁.

死锁产生的四个必要条件

  • 互斥使用: 即当资源正在被一个线程使用时, 别的线程不能使用.
  • 不可抢占: 资源请求者不能强制从资源占有者手中夺取资源, 资源只能由资源占有者主动释放.
  • 请求和保持: 即当资源请求者在请求其他的资源的同时保持对原有资源的占有.
  • 循环等待: 即存在一个等待队列, 线程1占有线程2的资源, 线程2占有线程3的资源, 线程3占有线程1的资源, 这样就形成了一个等待环路.
    如果以上四个条件都满足, 就会形成死锁.

解决方案

死锁的情况下如果打破上述任何一个条件, 便可让死锁消失.

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

检测手段

jstack查看线程状态, 包括死锁.

Callable创建线程

可以通过Future.get()阻塞当前线程, 并获取线程对象的返回值(相当于join方法, 只是get()方法提供了一个返回值)

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Test {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<String> c = new Callable<String>() {
            @Override
            public String call() throws Exception {
                System.out.println("call");
                return "ok";
            }
        };
        FutureTask task = new FutureTask(c);
        Thread t = new Thread(task);
        t.start();
        System.out.println(task.get());
        System.out.println("main");
    }
}

创建线程的方式:
(1) 继承Thread类
(2) 实现Runnable接口
(3) Callable创建线程

Lock体系

1. Lock

Lock简介
  • Lock没有Synchronized关键字隐式加锁解锁的便捷性, 但是却拥有锁获取和释放的可操作性, 可中断的获取锁以及超时获取锁等Synchronized关键字不具备的同步特性.
语法
Lock lock = new ReentrantLock();
lock.lock();
try{
.......
}finally{
lock.unlock();
}

synchronized同步块执行完成或者遇到异常时锁会自动释放; 而Lock必须调用unlock()方法释放锁, 因此得再finally块中释放锁.

Lock接口API

在这里插入图片描述
lock接口的实现子类
在这里插入图片描述

2. AQS

AbstractQueueSynchronized ---- 抽象的队列式的同步器

  • AQS实现了: 对同步状态的管理, 以及对阻塞线程进行排队, 等待通知等一系列底层的实现
  • AQS的核心也包括了这些方面: 同步队列, 独占式锁的获取和释放, 共享锁的获取和释放以及可中断锁,超时等待锁获取这些特性的实现.
  • 作用:对线程进行加锁, 释放锁操作
  • 原理: 双端队列 + CAS ----- 进行线程同步状态的管理

3. ReentrantLock

简述ReentrantLock

ReentrantLock重入锁, 是实现了Lock接口的一个类, 也是使用频率很高的一类锁. 支持重入性, 表示对共享资源可以重复加锁, 即当前线程获取该锁后再次获取该锁不会被阻塞.

使用语法
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// working
} finally {
lock.unlock()
}
重入性(重入锁)

要想支持重入性, 就要解决两个问题:

  1. 在线程获取锁的时候, 如果已经获取锁的线程是当前线程的话则直接再次获取成功.
  2. 如果锁被获取n次, 那么只有锁在释放同样的n次之后, 该锁才算是完全释放成功了.
公平锁与非公平锁
  1. 公平锁每次都是从同步队列中的第一个节点获取到锁, 而非公平锁则不一定, 有可能这个线程刚释放锁又重新获取到锁.
  2. 所有线程加锁的时候, 是否按照存放在队列的时间顺序来加锁(设置线程同步状态), 满足时间顺序就是公平锁, 不满足就是非公平锁
公平锁vs非公平锁
  1. 公平锁每次获取到的锁是同步队列中的第一个节点, **保证了请求资源时间上的绝对顺序,**而非公平锁有可能刚释放锁的线程下次继续获取该锁, 有可能导致其他线程永远获取不到锁, 造成"饥饿"现象.
  2. 公平锁为了保证时间上的绝对顺序, 需要频繁的切换上下文, 而非公平锁会降低一定的上下文切换, 降低性能开销. 因此, ReentrantLock默认选择的是非公平锁, 是为了减小一部分上下文切换, 保证了系统有更大的吞吐量.
独占锁和共享锁
  • 独占锁: 只有一个线程持有线程同步状态(加锁)
  • 共享锁: 多个线程可以持有线程同步状态

4. Semaphore/CountDownLatch

    // 使用join等待
    @Test
    public void t1() throws InterruptedException {
        Thread[] threads = new Thread[20];
        for (int i = 0; i < 20; i++) {
            final int j = i;
            threads[i] = new Thread(() -> {
                System.out.println(j);
            });
            threads[i].start();
        }
        for (int i = 0; i < 20; i++) {
            threads[i].join();
        }
        System.out.println("main");
    }
// 使用CountDownLatch进行等待操作
@Test
    public void t2() throws InterruptedException {
        CountDownLatch cdl = new CountDownLatch(20);//定义初始值为20的计数器
        for (int i = 0; i < 20; i++) {
            final int j = i;
            new Thread(() -> {
                System.out.println(j);
                cdl.countDown();//数量--操作
            }).start();
        }
        cdl.await();//数量等于0, 往下执行, 否则当前线程阻塞等待countDown()方法--
        System.out.printf("main");
    }
// 使用Semaphore进行等待操作
@Test
    public void t3() throws InterruptedException {
        Semaphore s = new Semaphore(0);//定义许可证数量为0
        for (int i = 0; i < 20; i++) {
            final int j = i;
            new Thread(() -> {
                System.out.println(j);
                s.release();//许可证数量++, 也可以指定增加的数量
            }).start();
        }
        // 获取指定数量的许可证, Semaphore中许可证的数量减去acquire的数量,
        // 如果获取不到这么多数量的许可证(减去之后为负数), 当前线程会阻塞等待release()方法++
        s.acquire(20);
        System.out.printf("main");
    }

 /**
     * 使用Semaphore进行有限的资源访问
     * 模拟http请求的服务端:
     * 假设同一个时间点, 只允许同时处理1000个并发的http请求
     */
    @Test
    public void t() throws InterruptedException {
        Semaphore s = new Semaphore(1000);//定义许可证数量为0
        for (;;) {
            s.acquire();//许可证数量--, 如果许可证数量等于0, 就阻塞等待
            new Thread(() -> {
                try {
                    // 处理http请求的逻辑, 可能比较耗时
                } finally {
                    s.release();
                }
            }).start();
        }
    }

ThreadLocal

1. 概念

  • ThreadLocal用于提供线程局部变量, 在多线程环境中可以保证各个线程里的变量独立于其他线程的变量. 也就是说ThreadLocal可以为每个线程创建一个 单独的变量副本.
  • 多个线程中使用ThreadLocal, 是操作自己线程独立的变量, 线程之间互不相关.
  • ThreadLocal的作用与同步机制有点相反, 同步机制是为了保证多线程环境下数据的一致性; 而ThreadLocal是保证了多线程下数据的独立性.
public class Test {
    private static String commStr;
    private static ThreadLocal<String> threadStr = new ThreadLocal<String>();
    public static void main(String[] args) {
        commStr = "main";
        threadStr.set("main");
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                commStr = "thread";
                threadStr.set("thread");
                System.out.println(threadStr.get());
            }
        });
        thread.start();
        try {
            thread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(commStr);
        System.out.println(threadStr.get());
    }
}

对于ThreadLocal类型的变量, 在一个线程中设置值, 不影响其在其他线程中的值. 也就是ThreadLocal类型的值在每个线程中是独立的.

2. 原理

Thread类中有一个ThreadHashMap的数据结构, 用来保存线程对象的变量

  • get(), set(), remove()方法都会获取到当前线程, 然后通过当前线程获取到ThreadHashMap, 如果ThreadHashMap为null, 则会创建一个ThreadHashMap, 并给当前线程.
...
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
...
  • 在使用ThreadLocal类型变量操作的时候, 都会通过当前线程获取到ThreadHashMap来操作. 每个线程的ThreadHashMap是属于线程自己的, ThreadLocalMap中维持的值也是属于线程自己的.这就保证了ThreadLocal类型的变量在每个线程中是独立的, 在多线程下不会互相影响

ConcurrentHashMap(重点)

面试题:

  1. ConcurrentHashMap的读是否要加锁,为什么?
  2. ConcurrentHashMap的锁分段技术?
  3. ConcurrentHashMap的迭代器是强一致性的迭代器还是弱一致性的迭代器?
  4. Mashmap和ConcurrentHashMap怎么确定key的唯一性?
  5. HashTable和HashMap、ConcurrentHashMap?
  6. ConcurrentHashMap的原理使用?
  7. ConcurrentHashMap在jdk1.8做了哪些优化?
  8. ConcurrentHashMap如何实现线程安全?

ConcurrentHashMap简介

基本特点
  • 和HashMap功能基本一致, 只要是解决了HashMap的线程不安全问题
  • JDK1.7的基本设计理念就是切分成多个Segment块, 默认是16个, 也就是说并发度是16, 可以在初始化时显式指定, 后期不能修改, 每个Segment里面可以近似看成是一个HashMap, 每个Segment快都有自己独立的ReentrantLock锁, 所以并发操作时, 每个Segment互不影响; 在JDK1.8中, 将Segment块换成了Node, 每个Node有自己的锁, 即每个Node都有自己的并发度.
  • 不允许空值和空键, 否则会抛出异常.
JDK1.7下

在这里插入图片描述

  • 底层数据结构也是数组+链表.HashEntry是链表的结点
  • 采用了segment分段锁技术, 在多线程并发更新操作的时候, 对同一个segment进行同步加锁, 保证数据的安全. 这样就可以基于不同的segment进行并发写操作.
  • 同步的实现方式是基于ReentrantLock锁机制(Segment继承自ReentrantLock)
  • 和HashMap一样, 同样存在hash冲突时, 链表查询效率低的问题.
JDK1.8下

在这里插入图片描述

  • 底层数据结构与HashMap1.8一样, 都是基于数组+链表+红黑树
  • 支持多线程的并发操作, 实现原理是: CAS + Synchronized保证线程安全
  • put方法存放元素时: 通过key对象的hashcode计算出数组的索引, 如果没有Node, 则使用CAS尝试插入元素, 失败则无条件自旋知道插入成功; 如果存在Node, 则使用synchronized锁住该Node元素(链表/红黑树的头结点), 在执行插入操作.

1.7和1.8版本都存在的特性

  1. 键和值迭代器为弱一致性迭代器, 创建迭代器后, 可以对元素更新
  2. 读操作没有加锁,因为value都是volatile修饰的, 保证了可见性, 所以是安全的.
  3. 读写分离可以提高效率: 多线程对于不同的Node/Segment的插入/删除是可以并发并行执行的, 对于同一个Node/Segment的写操作是互斥的. 读操作都是无锁操作, 可以并发,并行执行.

1.7到1.8, ConcurrentHashMap的变化

  1. 锁方面: 由分段锁(Segment继承自ReentrantLock)升级为CAS+Synchronized实现
  2. 数据结构方面: 将Segment换成了Node, 每个Node独立, 原来默认的并发度为16, 变成了每个Node都独立, 提高了并发度.
  3. hash冲突: 1.7发生hash冲突用链表存储, 1.8先使用链表存储, 后面满足条件后(如果添加元素的链表节点个数超过8)转为红黑树优化查询.

HashMap, HashTable, ConcurrentHashMap的区别

在这里插入图片描述

  • HashMap允许键和值为null, HashTable和ConcurrentHashMap不允许
  • HashMap不保证线程安全, HashTable和ConcurrentHashMap是线程安全的
  • 对于效率问题:
    ♦ 因为HashMap不保证线程安全, 所以效率最高, 适合用于单线程场景;
    ♦ 而HashTable是使用synchronized修饰方法实现线程安全, 锁住整个HashTable, 效率非常低;
    ConcurrentHashMap则是使用CAS+synchronized实现线程安全( put方法存放元素时: 通过key对象的hashcode计算出数组的索引, 如果没有Node, 则使用CAS尝试插入元素, 失败则无条件自旋知道插入成功; 如果存在Node, 则使用synchronized锁住该Node元素(链表/红黑树的头结点), 在执行插入操作.)所以效率比HashTable高, ConcurrentHashMap已经可以完全取代HashTable.
  • 对于扩容机制
    ♦ HashTable初识size为11, 扩容: newsize=oldsize2+1
    ♦ HashMap和ConcurrentHashMap初识size都是16, 扩容为newsize=oldsize
    2, size一定为2的n次幂.
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值