多线程进阶

1.常见的锁策略

        锁策略是多线程编程中用于控制线程访问共享资源的方式。不同的锁策略适用于不同的场景,选择合适的锁策略可以提高程序的性能和可靠性。

1.1乐观锁和悲观锁

乐观锁:假设并发冲突很少发生,因此在访问共享资源时,不加锁,而是在更新时检查是否有冲突

悲观锁:假设并发冲突一定会发生,因此在访问共享资源时,总是先加锁,确保其他线程无法同时访问

synchronized初始使用乐观锁策略,当发生锁竞争比较频繁的时候,就会自动切换成悲观锁策略

乐观锁的一个重要功能就是要检测出数据是否发生访问冲突,我们可以引入"版本号"来解决,也就是说针对记录的修改操作,提交的版本必须大于记录当前版本才能执行更新操作

1.2轻量级锁和重量级锁

轻量级锁:基于CAS操作的锁机制,假设大多数情况下锁竞争很少,因此通过CAS操作来避免使用操作系统级别的锁,从而减少锁开销

重量级锁:通过操作系统的互斥量实现锁的机制.当锁竞争激烈时,轻量级锁会升级为重量级锁,来确保线程安全

多数情况下,乐观锁,也是一个轻量级锁;悲观锁,也是一个重量级锁,但是这并非完全保证的

其次,我们所提到的CAS以及互斥量在之后会说到

1.3自旋锁和挂起等待锁

自旋锁:是一种典型的轻量级锁,自旋锁是一种忙等待的锁机制.当线程尝试获取锁时,如果所已经被其他线程持有,当前线程不会放弃cpu,而是通过循环不断尝试获取锁,直到成功

挂起等待锁:是一种典型的重量级锁,它是一种阻塞等待的锁机制.当线程尝试获取锁时,如果锁已被其他线程持有,当前线程会放弃CPU,进入阻塞状态,等待被唤醒

synchronized中的轻量级锁策略大概率就是通过自旋锁的方式实现的

1.4互斥锁和读写锁

互斥锁:是一种独占锁,同一时间只允许一个线程访问共享资源.无论是读操作还是写操作,都需要先获取到锁

读写锁:读写锁将锁分为读锁和写锁

  1. 读锁:允许多个线程同时获取读锁,进行读操作
  2. 写锁;写锁是独占的,同一时间只允许一个线程获取写锁,进行写操作

读锁和读锁之间没有互斥;写锁和写锁之间,存在互斥;写锁和读锁之间,存在互斥

1.5公平锁和非公平锁

公平锁:公平锁按照线程请求锁的顺序分配锁,保证先请求锁的线程先获取锁.公平锁通过维护一个等待队列来实现

非公平锁;非公平锁不保证线程获取锁的顺序,允许新请求锁的线程插队,非公平锁通过抢占机制来实现

操作系统和java中的synchronized原生都是"非公平锁"

操作系统这里针对加锁的控制,本身就依赖于线程调度顺序的,这个调度顺序是随机的,不会考虑到这个线程等待了锁多久,要想实现公平锁,就得引入额外的东西

1.6可重入锁和不可重入锁

不可重入锁:不允许同一个线程多次获取同一把锁,如果一个线程尝试再次获取已经持有的锁,会导致死锁

可重入锁:允许同一个线程多次获取同一把锁.每次获取锁时,锁的计数器加1;每次释放锁,锁的计数器减1.只有当计数器为0时,锁才被完全释放.

synchronized是可重入锁

2.CAS

        CAS(Compare-And-Swap) 是一种用于实现并发控制的原子操作。它是无锁编程(Lock-Free Programming)的核心技术之一,广泛应用于多线程环境中,用于实现线程安全的操作。CAS 操作通过硬件指令直接支持,能够在不使用锁的情况下保证操作的原子性。

2.1CAS的核心思想

CAS操作包含三个操作数:

  1. 内存地址(V):需要更新的变量的内存地址
  2. 预期值(A):变量当前的值
  3. 新值(B):希望将变量更新为的值

操作逻辑:如果内存地址V的值等于预期值A,则将V的值更新为新值B;如果内存地址V的值不等于预期值A,则操作失败,不进行任何更新

CAS示例的伪代码

boolean CAS(V, A, B) {
    if (V == A) {
        V = B;
        return true;
    } else {
        return false;
    }
}

在上述交换过程中,我们并不关心B的后续情况,而是更关心V这个变量的情况

2.2CAS的应用场景

2.2.1实现原子类

package thread;

import java.util.concurrent.atomic.AtomicInteger;

public class ThreadDemo28 {
    public static void main(String[] args) throws InterruptedException {
        //这些原子类,都是基于CAS实现了自增 自减等操作,此时进行这类操作不需要加锁,线程也是安全的
        AtomicInteger count = new AtomicInteger(0);
        //使用原子类,来解决线程安全问题
        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();    //count++
//                count.incrementAndGet();  ++count
//                count.getAndDecrement();  count--
            }
        });

        Thread t2 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();


        System.out.println(count.get());
    }
}

通过结果我们可以发现,当我们的count是AtomicInteger类的一个实例时,我们通过两个线程来计算count的最终值,我们可以发现,其实现了线程安全,我们得到的是我们预期想要得到的结果.

2.2.2AtomicInteger的深层探究

我们先来看一下AtomicInteger的伪代码实现

class AtomicInteger {
    private int value;

    public int getAndIncrement() {
        // 步骤 1:读取当前值
        int currentValue = value;

        // 步骤 2:尝试更新值
        while (true) {
            // 尝试将 value 从 currentValue 更新为 currentValue + 1
            if (cas(value, currentValue, currentValue + 1)) {
                // 如果 CAS 成功,返回更新前的值
                return currentValue;
            } else {
                // 如果 CAS 失败,重新读取 value 的值
                currentValue = value;
            }
        }
    }
}

我们现在假设,两个线程同时调用,getAndIncrement()方法

1.两个线程都读取到value的值到currentValue里面(也就是读取到各自的工作内存中)

2.线程1先执行CAS操作,此时currentValue的值和value的值相同,直接对value赋值

    3.线程2在执行CAS操作,此时发现currentValue的值和value的值不一样,所以不进行赋值,进入循环

    4.线程2接下来第二次执行value操作

    这样我们更加直观地看到,为什么实现一个原子类,不要使用重量级锁,就能完成多线程的自增操作

    2.2.3实现自旋锁

    伪代码示例

    public class SpinLock {
        private Thread owner = null;
    
        public void lock() {
            // 尝试获取锁
            while (true) {
                // 检查锁是否被其他线程持有
                if (owner == null) {
                    // 尝试将锁的所有者设置为当前线程
                    if (CAS(this.owner, null, Thread.currentThread())) {
                        // 如果 CAS 成功,当前线程获取锁,退出循环
                        break;
                    }
                }
                // 如果锁被其他线程持有,继续自旋等待
            }
        }
    
        public void unlock() {
            // 释放锁,将所有者设置为 null
            this.owner = null;
        }
    }
    
    

    2.3ABA问题

    ABA问题具体来说:

    1. 线程1读取变量的值为A
    2. 线程2将变量的值从A改为B,在改回A
    3. 线程1执行CAS操作,发现变量的值仍然是A,误认为变量没有修改

    解决方法:

    版本号机制,为变量增加一个版本号,每次修改变量的时候,版本号增加1.CAS操作不仅比较值,还比较版本号

    3.Synchronized原理

    3.1对象头

    在java中,每个对象都有一个对象头,对象头中包含了以下信息:

    Mark Word:存储对象的哈希码,锁状态等信息

    Klass Pointer:指向对象的类元数据

    synchronized的锁信息就存储在Mark Word中

    3.2Monitor机制

    Synchronized 的锁机制是基于 Monitor(监视器) 实现的。每个 Java 对象都与一个 Monitor 相关联,Monitor 的主要组成部分包括:

    1. Owner:持有锁的线程。
    2. EntryList:等待获取锁的线程队列。
    3. WaitSet:调用 wait() 方法后进入等待状态的线程队列。

    3.3锁的升级

    为了优化synchronized的性能,JVM引入了锁升级机制,锁的状态会从低到高逐步升级

    1. 无锁状态:对象刚创建,处于无锁状态
    2. 偏向锁:当第一个线程访问锁时,JVM会将锁升级为偏向锁,偏向锁并不是真正的加锁,仅仅是做了一个标记
      1. 如果没有后续线程来竞争该锁,那么就不用进行其他同步操作了
      2. 如果有后续线程来竞争该锁,那么就取消偏向锁状态,转而进入下一阶段,轻量级锁状态
    3. 轻量级锁:此处的轻量级锁通过CAS来实现的,
      1. 通过CAS检查并更新一块内存,
      2. 如果更新成功则认为加锁成功;
      3. 如果更新失败,则认为锁被占用,继续自旋式等待
    4. 重量级锁:当轻量级锁竞争激烈时,JVM会将锁升级为重量级锁,重量级锁通过操作系统的互斥量实现,确保线程安全

    3.4锁消除和锁粗化

    3.4.1锁消除

            编译器和JVM判断锁是否可以消除,如果可以,直接消除

    3.4.2锁粗化

            锁粗化是指JVM将多个连续的锁操作合并为一个更大的锁操作,从而减少锁的获取和释放的次数

    public void method() {
        synchronized (this) {
            // 操作 1
        }
        synchronized (this) {
            // 操作 2
        }
        synchronized (this) {
            // 操作 3
        }
    }
    public void method() {
        synchronized (this) {
            // 操作 1
            // 操作 2
            // 操作 3
        }
    }

    4.JUC的常见类

    JUC(Java Util Concurrent) 是 Java 并发编程的核心工具包,位于 java.util.concurrent 包中。它提供了丰富的并发工具类,用于简化多线程编程。

    4.1Callable接口

    Callable是一个泛型接口,定义了一个可以返回结果的任务.与Runnable不同,runnable描述的任务没有返回值,如果需要用一个线程单独计算出某个结果,用Callable是比较合适的

    接下来我们使用Callable接口计算1+2+3+...+100的和

    package thread;
    
    import java.util.concurrent.Callable;
    import java.util.concurrent.ExecutionException;
    import java.util.concurrent.FutureTask;
    
    public class ThreadDemo29 {
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            //使用callable 计算1 + 2 + 3 .....+100
            Callable<Integer> callable = new Callable<Integer>() {
                @Override
                public Integer call() throws Exception {
                    int sum = 0;
                    for (int i = 1; i <= 100; i++) {
                        sum += i;
                    }
                    return sum;
                }
            };
    
            FutureTask<Integer> futureTask = new FutureTask<>(callable);
            Thread t = new Thread(futureTask);
            t.start();
    
            Integer result = futureTask.get();
            //get会发生阻塞,直到callable执行完成   get才阻塞完毕,才能获取到结果
            //get会抛出两个异常
            System.out.println(result);
        }
    }
    

    我们可以发现,我们并没有将callable直接提交给线程t,而是用FutureTask将callable包装了一下,原因是因为:

    1.callable只是一个接口,定义了任务的逻辑(call()方法),但是本身不能直接提交给线程执行,其本身只是定义了任务的逻辑,但是无法提供任务的状态和结果的查询

    2.FutureTask实现了Runnable接口,可以将Callable转换为一个可执行的任务,同时提供了对任务状态和结果的查询方法(get(),isDone(),cancel()等方法)

    3.FutureTask 实现了 Runnable 接口,因此可以直接作为任务提交给线程池,也可以通过 Thread 手动启动。这种灵活性使得 FutureTask 可以用于更多的场景。

    4.Callable 的 call() 方法可以抛出异常,但线程池无法直接处理这些异常。 FutureTask 会捕获 Callable 抛出的异常,并在调用 get() 时重新抛出,使得异常可以被调用方处理。

    我们可以用一个形象的比喻来比喻他们之间的关系

    Callable就相当于厨师,FutureTask就相当于外卖平台,厨师只负责做饭(任务),外卖平台负责管理厨师的饭(任务),并且提供一个接口给顾客,厨师不能直接与顾客沟通订单状态,而外卖平台(FutureTask)就充当了一个中间人,负责管理订单的状态和结果.

    4.2ReentrantLock

            它是一种可重入锁,与synchronized相比,其提供了更灵活的锁机制,支持公平锁,非公平锁等高级功能,相比于synchronized通过wait/notify每次唤醒一个随机线程来比,ReentrantLock搭配Condition可以更精确控制唤醒某个指定的线程.

    4.2.1ReentrantLock基本用法

    1. lock():加锁,如果获取不到锁就死等
    2. trylock(超时时间):加锁,如果获取不到锁,等待一定时间之后就会放弃加锁
    3. unlock():解锁
    package thread;
    
    import java.util.concurrent.locks.ReentrantLock;
    
    public class ThreadDemo30 {
        public static void main(String[] args) {
            ReentrantLock reentrantLock = new ReentrantLock();//参数写为true就是公平锁
    //        try{
    //            reentrantLock.lock();
    //        }finally {
    //            reentrantLock.unlock();
    //        }
            boolean result = reentrantLock.tryLock();
            try{
                if (result){
    
                }else {
    
                }
    
            }finally {
                if (result){
                    reentrantLock.unlock();
                }
            }
        }
    }
    

    我们把unlock放入finally中,这样就能确保unlock一定可以执行的到.

    5.信号量(Semaphore)

    信号量用于控制同时访问特定资源的线程数量,本质上来说就是一个计数器,描述了可用资源的个数

    (与操作系统的信号量是一个东西,只不过这个是java把操作系统的原生信号量封装了一下)

    代码示例:

    package thread;
    
    import java.util.concurrent.Semaphore;
    
    public class ThreadDemo31 {
        public static void main(String[] args) throws InterruptedException {
            Semaphore semaphore = new Semaphore(3);
            semaphore.acquire();
            System.out.println("执行一次p操作");
            semaphore.acquire();
            System.out.println("执行一次p操作");
            semaphore.acquire();
            System.out.println("执行一次p操作");
            semaphore.acquire();
            System.out.println("执行一次p操作");
    
    
            semaphore.release();
    
        }
    }
    

    6.线程安全的集合类

    6.1多线程环境使用ArrayList

    1.自己加锁,使用synchronized和ReentrantLock

    2.Collections.synchronizedList(new ArrayList);这里提供一些ArrayList相关的方法,同时也是带锁的,用这个把集合类一包装

    3.CopyOnWriteArrayList

    "写时拷贝",当针对ArrayList进行读操作,就无需任何额外的工作,如果进行写操作,则要拷贝一份新的ArrayList,针对新的进行修改,修改过程中如果有读操作就继续读旧的这份数据,当修改完毕使用心得替换旧的.

    例如服务器的"热加载"就可以使用写时拷贝的思路,加载过程中,请求仍然基于旧的配置进行工作.当新对象加载完毕,使用新对象代替旧对象.

    6.2多线程环境使用哈希表

    在多线程环境下使用哈希表可以使用:Hashtable,ConcurrentHashMap

    6.2.1Hashtable

    只是简单的把关键方法都添加上了synchronized关键字,但是这也导致一个问题,只要操作哈希表的任意元素都会产生加锁,也就是锁冲突.

    但是我们实际在对好戏表进行操作的时候,当我们需要修改的元素的值,并没有指向相同的哈希地址,此时我们的同时对两个元素进行修改的操作是没有冲突的,如下图所示:

    如果线程1修改元素1,线程2修改元素2,这是有线程安全问题的;如果线程1修改元素1,线程2修改元素3这是没有线程安全问题了,这种情况下完全不需要加锁.

    6.2.2ConcurrentHashMap

    相比于Hashtable,ConcurrentHashMap做出了一系列调整优化

    1. 读操作没有加锁,但是使用volatile来保证内存可见性
    2. 加锁的方式仍然是synchronized,但是不是锁整个对象,而是锁"桶"(每个链表的表头节点)
    3. 充分利用了CAS特性,避免重量级锁出现
    4. 优化了扩容方式
      1. 触发条件:当哈希表中的元素数量超过阈值(sizeCtl)时,会触发扩容。阈值由负载因子(默认 0.75)和当前容量决定。
      2. 扩容机制:
        1. 扩容时,会创建一个新的、更大的 Node 数组(通常是原数组的两倍)。
        2. 使用多线程协作的方式进行扩容:每个线程可以负责迁移一部分数据(称为“分段迁移”)。
        3. 迁移过程中,使用 synchronized 锁定当前桶(Node),确保线程安全。
      3. 数据迁移:
        1. 将旧数组中的每个桶(Node)重新哈希到新数组中。
        2. 如果桶中是链表,会将链表拆分为两部分,分别迁移到新数组的不同位置。
        3. 如果桶中是红黑树,会将树拆分为两个链表,然后迁移到新数组。
      4. 并发控制:
        1. 使用 CAS 操作来更新扩容状态(如 sizeCtl)。
        2. 多个线程可以同时参与扩容,每个线程负责迁移一部分数据。
    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

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

    抵扣说明:

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

    余额充值