多线程---CAS,JUC组件和线程安全的集合类

(一).CAS

1.概念

CAS(Compare And Swap),表示"比较和交换",是一条CPU的指令,这就导致了CAS指令是一个原子的。

java 复制代码
boolean CAS(address, expectValue, swapValue) {
 	if (&address == expectedValue) {
 		&address = swapValue;
 			return true;
 	}
	return false;
}

上述的代码就是CAS的一个伪代码,实际上是一条指令。

第一个参数表示"内存地址",第二个参数表示"寄存器1"的值,第二个参数表示"寄存器2"的值

CAS就是进行判断,判断"内存地址"的值和"寄存器1"的值是否相等,如果相等则将"寄存器2"的值和"内存地址"进行交换,但是由于基本上只关心交换后"内存地址"中的值,而不关心"寄存器2"的值,所以此处就可以将这样的操作理解成"赋值",本质上是交换,基于交换实现了赋值

CAS本质上是CPU指令,操作系统就会把这个指令进行封装,提供一些api,就可以在C++中调用了,JVM又是基于C++实现的所以JVM也能够使用C++第哦啊用这样的CAS操作

2.用途

(1).实现原子类

Ⅰ.概念
java 复制代码
    public static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Object locker=new Object();
        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    count++;

                }
            }
        });

        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(count);
    }

在上面的例子中,我们通过加锁来破坏原子性,从来保证了线程的安全。但是,现在又认为加锁的效率比较低,于是就可以通过CAS中的原子类来实现count++,以确保性能,同时保证线程安全

原子类在java.util.concurrent.atomic包底下

原子类主要是用于多个线程同时修改一个变量

java 复制代码
    //基于"CAS"实现"原子类",避免加锁
    public static AtomicInteger count=new AtomicInteger(0); //初始值为0

    public static void main(String[] args) throws InterruptedException {
        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();//AtomicInterger是一个原子类,所以使用引用对象的时候要通过.的方式
            }
        });

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

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(count);
    }

getAndIncrement()等价于count++

incrementAndGet()等价于++count

addAndget(n)等价于count+=n

Ⅱ.AtomicInteger伪代码
java 复制代码
class AtomicInteger {
 	private int value;
 	public int getAndIncrement() {
 		int oldValue = value;
 		while ( CAS(value, oldValue, oldValue+1) != true) {
 			oldValue = value;
 		}
 		return oldValue;
    }
}

oldValue就是"寄存器",Java没有寄存器的使用,所以这里由int代替

getAndIncrement()就相当于自增的操作,首先先使用CAS指令,判断value内存和oldValues寄存器1的值是否相同如果相同则将oldValues+1"寄存器2"的值赋值给values,然后返回true,然后进行判断true!=true,所以不成立,然后返回oldValue,即返回自增前的值,此时oldValue为0,value为1

下图是在多线程的视角

(2).实现自旋锁

java 复制代码
public class SpinLock {
 	private Thread owner = null;
 	public void lock(){
 		// 通过 CAS 看当前锁是否被某个线程持有.  
 		// 如果这个锁已经被别的线程持有, 那么就⾃旋等待.  
 		// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.  
 		while(!CAS(this.owner, null, Thread.currentThread())){}
 	}
 	public void unlock (){
 		this.owner = null;
 	}
}

对于owner,如果null,那么锁就是空闲的,如果不为null,那么锁已经被某个线程给占有了

java 复制代码
while(!CAS(this.owner, null, Thread.currentThread())){}

对于这行代码,是实现自旋锁的核心逻辑代码。首先,先判断owner是否和null相等,如果相等,则就将当前线程的引用设置到owner中,如果owner和null不相等,那么就一直在while()循环中循环,即自旋效果。发现锁被占用,CAS不会执行交换,而是返回false。进入循环,再进行下一次的判定,由于循环体是空着的,所以整个循环速度非常快(忙等),但是一旦其他线程释放了锁,那么此线程就能第一时间拿到锁

3.CAS的缺陷

(1).缺陷原因

使用CAS确实能够保证线程安全,其核心就是先比较是否相等,即内存和寄存器是否相等。判断内存和寄存器是否相等,本质上就是在判定是否有其他线程插入进行做了一些修改。如果发现寄存器和内存的值一致,就可以任务是没有现成穿插过来修改的,因此接下来的修改操作就是安全的。但是这里有一个问题,就是改了又改回去 。本来判定内存中的值是否为A,发现是A,说明没有线程改过,但是可能会存在一种情况,另一个线程从A改成了B,然后又从B改成了A。我们称上述问题为ABA问题。(汤老师取钱)

(2).解决方案

我们可以使用"版本号"来进行解决。此时,约定这个"版本号"只能加不能减,那么就可以有效的避免ABA问题

(二).JUC组件

1.概念

JUC 组件,指的是java.util.concurrent包里面的一些和多线程相关的工具

2.相关组件

(1).Callable接口和FutureTask类

Callable接口和Runnable接口属于并列的关,同时Callable接口支持泛型。我们使用Runnable任务的时候,需要重写里面的run()方法;当我们使用Callable任务的时候,我们需要重写里面的call()方法,并且call()方法是有返回值的,返回值的类型就是我们所写的泛型类型

示例

通过图片中代码,可以看到,报错了。

这是因为Thread的构造方法没有提供传入Callable的对象

所以这个时候,我们就需要借助FutrueTask类了

Thread本身不提供获取结果的方法,所以就需要凭借FutureTask对象来拿到结果集

这样做的目的是为了"解耦合",Thread类就是线程,希望和"任务"这个概念剥离开,更不希望关心任务是什么样的任务,也不希望关心是否有返回值

java 复制代码
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //注意:此处的Callable只是定义了一个"带有返回值"的任务,并没有真正的执行,所以想要执行这个任务,还需要搭配Thread对象
        Callable<Integer> callable=new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int count=0;
                for (int i = 0; i <=100; i++) {
                    count+=i;
                }
                return count;
            }
        };
        //由于Thread对象没有提供一个带有callable的构造方法,所以我们需要借助一下FutureTask,相当于多包了一层
        //这是因为,call()方法是有返回值的,Thread类本身并没有提供获取结果的方法,所以就需要凭futureTask对象来拿到结果
        FutureTask<Integer> futureTask=new FutureTask<Integer>(callable);

        Thread thread=new Thread(futureTask);

        thread.start();
        //get()操作就是获取到futureTask的返回值,这个返回值来自于Callable的call()方法
        //get()方法可能会发生阻塞,如果当前线程执行完毕,get()就会拿到返回结果
        //如果当前线程没有执行完毕,get()就会一致阻塞
        System.out.println(futureTask.get());
    }

总结

创建线程的方法

①.继承Thread类(定义单独的类/匿名内部类)

②.实现Runnable接口(定义单独的类/匿名内部类)

③.通过lambda表达式

④.实现Callable接口(定义单独的类/匿名内部类)

⑤.使用线程池 ThreadFactory

(2).ReentrantLock

可重入锁,和synchronized是并列的关系

synchronized和ReentrantLock的区别

①.synchronized是关键字(内部实现是JVM内部通过C++,实现的),ReentrantLock是标准库的类,由Java实现的

②.synchronized是通过代码块控制加锁解锁,Reentrantlock是通过lock()和unlock()方法来控制加锁和解锁

③.ReentrantLock除了提供lock()和unlock()方法之外,还提供了tryLock()方法,用来判断是否加锁成功,如果成功,则返回true,如果失败,返回false,不会发生阻塞。同时tryLock()还提供了设置超时时间的版本,等待时间达到超时时间之后再返回true或者false

④.ReentrantLock默认是非公平锁,但是ReentrantLock提供了公平锁的实现

⑤.ReentrantLock搭配的等待通知机制是Condition类,相比wait()和notify()来说,功能更强大一些

示例

java 复制代码
    public static int count=0;
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock reentrantLock=new ReentrantLock();
        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                reentrantLock.lock();   //加锁
                count++;
                reentrantLock.unlock();  //解锁
            }
        });


        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                reentrantLock.lock();
                count++;
                reentrantLock.unlock();
            }
        });
        
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(count);
    }

(3).信号量 Semaphore

Ⅰ.概念

信号量,类似于一个计数器,描述了某种可用资源的个数。信号量能够协调多个进程之间的资源费配,也能协调多个线程之间的资源分配

信号量在申请资源(P操作)的时候,计数器就会-1,释放资源(V操作)的时候就会+1,当计数器为0的时候,再申请就会阻塞等待,相当于"锁"的更广泛的推广

在java中,acquire()方法就相当于申请资源,release()方法就相当于释放资源

Ⅱ.示例
①.
java 复制代码
    public static void main(String[] args) throws InterruptedException {
        //信号量                           可用资源是"4"个
        Semaphore semaphore=new Semaphore(4);//计数器的初始值
        semaphore.acquire();
        System.out.println("申请第一个资源");
        semaphore.acquire();
        System.out.println("申请第二个资源");
        semaphore.acquire();
        System.out.println("申请第三个资源");
        semaphore.acquire();
        System.out.println("申请第四个资源");
    }

可以看到Semaphore中有四个资源,我们申请了四次,四次都申请下来了

②.
java 复制代码
    public static void main(String[] args) throws InterruptedException {
        //信号量                           可用资源是"3"个
        Semaphore semaphore=new Semaphore(3);//计数器的初始值
        semaphore.acquire();
        System.out.println("申请第一个资源");
        semaphore.acquire();
        System.out.println("申请第二个资源");
        semaphore.acquire();
        System.out.println("申请第三个资源");
        semaphore.acquire();
        System.out.println("申请第四个资源");
    }

可以看到,Semaphore中的可用资源有3个,但是申请了4次,在申请第四次的时候,发生了阻塞等待

③.信号量的特殊情况,将信号量的可用资源数设置为1,即取值要么是1,要么是0,此时就等价于"锁"
java 复制代码
    public static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore=new Semaphore(1);

        Thread thread1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();//申请资源
                    count++;
                    semaphore.release();//释放资源
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread thread2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();//申请资源
                    count++;
                    semaphore.release();//释放资源
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(count);
    }

(4).CountDownLatch

Ⅰ.概念

在进行多线程编程的时候,经常会把一个大的任务拆分成多个子任务,然后通过多线程执行这些子任务,从而提高程序的效率。

我们可以通过CountDounLatch类来衡量这多个子任务是否都完成了,从而判断整个任务是否都完成了

Ⅱ.思路

①.通过构造方法指定参数,描述拆成了多少个任务,例如拆成10个子任务

②.每个任务执行完毕之后都调用一次countDown方法,当调用了10次countDown()方法之后,表示所有的子任务都执行完毕了

③.主线程中调用await()方法,等待所有任务执行完毕之后await()就会返回,否则就会阻塞等待

Ⅲ.示例
java 复制代码
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch=new CountDownLatch(10);  //表示任务的个数
        ExecutorService threadPool = Executors.newFixedThreadPool(4);//创建四个线程
        for (int i = 0; i < 10; i++) {
            int id=i;
            threadPool.submit(()->{
                System.out.println("子任务开始执行:"+id);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("子任务结束执行 "+id );

                countDownLatch.countDown();
            });
        }
        //等待所有的任务都结束
        countDownLatch.await();

        System.out.println("所有的线程都执行完了");
    }

(三).线程安全的集合类

1.多线程下使用ArrayList

(1).自行加锁[推荐]

只需要分析清楚加锁的情况,要把哪些代码打包在一起,成为一个"原子"操作

(2).Collections.synchronizedList(new ArrayList)

这个相当于给ArrayList套了层壳,返回的List的所有的关键方法都带有synchronized

(3).使用CopyOnWriteArrayList

Ⅰ.概念

写时拷贝,不需要加锁,当我们要进行写操作时,不是在原来的ArrayLsit上进行修改,而是先拷贝一份,在拷贝的那一份上进行修改,当修改结束之后,再将原数组的引用指向拷贝的这份数组上

再拷贝数组的过程中,如果有其他线程在读,就直接读取旧版本的数据

虽然在拷贝的过程中,不是原子的,但是由于提供了就把呢不能的数据,不影响其他线程的读取,新版本数据拷贝完毕后,直接进行引用的修改

注意:整个过程中都没有加锁,所以不会涉及到阻塞

Ⅱ.优点

适用于特定的场景,例如,服务器进行重新加载配置

服务器正在运行,当需要进行修改的时候,正常来说,修改配置文件之后,不能够立即生效,需要重启一下服务器。很多服务器也会提供配置重加载(reload)。配置就是被读取到服务器的内存中,以数组/哈希来存储,服务器代码中的其他逻辑就会读取这些数组/哈希中的值,此时程序员手动修改配置文件之后,手动触发reload功能,服务器就会创建新的数组/哈希,加载新的配置,加载完毕之后,使用新配置代替旧配置

Ⅲ.缺点

①.当数组特别大的时候,非常低效

②.如果多个线程同时修改,也会出现问题

例如,如果第一个线程修改前三个数据,第二个线程修改后三个数据,那么此时就会出错,可能最后引用指向了第二个线程修改后的数组,那么就不一定确保前三个数据被完整的修改

2.多线程下使用阻塞队列

这个在前面介绍过

①.ArrayBlockingQueue 基于数组实现的阻塞队列

②.LinkedBlockingQueue 基于链表实现的阻塞队列

③.PriorityBlockingQueue 基于堆实现的带有优先级的阻塞队列

④.TransferQueue 最多只包含一个元素的阻塞队列

3.多线程下使用哈希表

(1).HashMap

线程不安全

(2).HashTable

虽然是线程安全的,但是不建议使用,因为HashTable是给所有public修饰的方法都加入了synchronized,这样会导致效率低

(3).ConCurrentHashMap

Ⅰ.介绍

效率更高,按照桶级别进行加锁

要介绍清除ConCurrentHashMap,还是要先通过HashTable进行入手

这是HashTable的构造

此时任意两个线程访问任意的两个不同元素,都会产生"锁竞争"

如上图所示,此时就会出现"锁竞争",但是如果修改的两个元素在不同的链表上,那么本身就不会涉及到线程安全问题

如果修改的是同一个链表上的两个元素,可能会出现线程安全问题,例如两个元素查到同一个元素的后面就可能出现锁竞争

但是,这种情况下是不涉及到线程安全的,所有此时就可以进行优化,使用ConCurrentHashMap

ConCurrentHashMap是针对不同的对象进行加锁,此时就不会产生锁竞争的问题了

ConCurrentHashMap的时间开销相对于HashTable来说肯定是低,但是再怎么低也不可能比HashMap低,因为HashMap是线程不安全的,不需要锁。对于空间开销,其实还好。直接使用每个链表的头节点作为synchronized的锁对象即可

Ⅱ.问题
①.size问题

当一个链表插入元素,另一个链表也插入元素的时候此时这个size()就不好计算了,那么这个时候就可以引入我们上面介绍过的原子类来解决问题

②.扩容问题

扩容操作,采用了"化整为零"的方式来解决。一下子全部扩容会很耗时,所以把整个扩容的过程拆分成多次来完成,一旦触发扩容,不是通过一次put来完成,而是通过多次的put/get等操作来完成的

Ⅲ.ConCurrentHashMap的核心优化点

①.把锁整个表优化成了锁桶

②.使用"原子类"针对size进行维护

③.正对哈希扩容的场景,化整为零,确保每个操作的加锁时间不要太长

相关推荐
航Hang*2 小时前
VMware vSphere 云平台运维与管理基础——第5章:VMware vSphere 5.5 高级特性
运维·服务器·开发语言·windows·学习·虚拟化
不懂的浪漫2 小时前
mqtt-plus 架构解析(五):错误处理与 ErrorAction 聚合策略
java·spring boot·后端·物联网·mqtt·架构
kaico20182 小时前
python操作数据库
开发语言·数据库·python
zhangzeyuaaa2 小时前
Python变量的四种作用域
开发语言·python
范纹杉想快点毕业2 小时前
C语言全能实战教程
c语言·开发语言
呼啦啦5612 小时前
C++vector
java·c++·缓存
花千树-0102 小时前
MCP + Function Calling:让模型自主驱动工具链完成多步推理
java·agent·react·mcp·toolcall·harness·j-langchain
Benszen2 小时前
Linux容器:轻量级虚拟化革命
java·linux·运维
凸头2 小时前
Lombok 包底层浅析
java