多线程(进阶2) 1 CAS的应用场景两种典型应用场景原子类和自旋锁1.1 原子类续上集CAS是如何实现原子类的呢可以来看一段伪代码class AtomicInteger{private int value; #内存中的值public int getAndIncrement() {int oldValue value; #oldValue可以理解成寄存器1while ( CAS(value, oldValue, oldValue1) ! true) {oldValue value; #oldValue1可以理解成寄存器2}return oldValue #返回的是旧值}}如果执行CAS()发现内存中的值和寄存器中的值是相等的oldValue value就会把oldValue1的值赋值给valuevalue的值就会发生改变CAS就会返回true 就不进入循环直接返回oldValue 。相反如果内存中的值和寄存器中的值是不相等CAS交换操作不会进行并且会返回false进入while循环于是重新读取value的值到value中接下来进行第二次循环执行CAS()发现相等了就会……所以即使上述代码出现线程切换由于在进行自增之前先判断当前寄存器读到的值是否是“科学的值”如果是不科学的值就会进行重新读取。而使用内置类型的时候不会管科不科学直接加了。所以不需要加锁线程就是安全的。另一方面赋值和判断是原子的一个指令完成的无法构造出这样的调度顺序把赋值和判断之间加入其他逻辑。1.2 基于CAS实现自旋锁伪代码2 CAS的一个典型缺陷——ABA问题使用CAS能够线程安全。它的核心就是先比较“相等”内存和寄存器是否相等这里本质上判定是否有其他线程插入进来做了一些修改如果发现寄存器和内存中的值一致就可以认为是没有线程穿插过来修改因此就认为接下来的操作是线程安全的。本来判定内存的值是否是A发现果然是A说明没有其他线程修改过。但是实际上可能存在另一种情况另一个线程把内存的值从A修改为B再把B修改回为A。大部分情况下即使出现了ABA最终问题也不大。只有一些极端情况小概率才会出现一些严重的bug。极端情况使用数值来判定中间是否有线程穿插修改可以加也可以减1特定情况多个线程 2第三个线程得是在巧妙的时机也得是巧妙的数值那如何解决呢换成其他的指标约定只能加不能减可以有效避免ABA问题。那如何换成其他的指标呢比如可以引入另一个概念“版本号”整数每次修改一次数值版本号就13 JUC中的一些组件JUCjava.util.concurrent包里面的组件就是一些和多线程相关的一些工具3.1 Callable接口Callable接口和Runnable接口是并列关系。区别之一是Runnable是重写run方法返回值是void关注的是过程。而Callable是重写call方法有返回值返回值类型是泛型参数可以根据需要获取结果。代码示例注意此处只是定义了一个带有返回值的任务要想执行还需搭配Thread对象这就是Callable接口和Runnable接口另一个区别Thread的构造方法没有提供传入Callable对象的版本。那怎么办呢在传入Callable之前定义另外一个类FutureTask相当于包装了一层那为什么包装一层呢Thread本身不提供获取结果的方法的就需要凭FutureTask对象拿到结果这里的get就是获取到FutureTask的返回值这个返回值来自于Callable的call方法但是需要注意的是get可能会阻塞如果线程执行完毕get拿到结果如果未执行完毕get就会阻塞。使用Runnable实现上述逻辑3.2 ReentrantLock可重入它和synchronized是并列的关系用“lock”和“unlock”代码示例synchronized和ReentrantLock的区别1synchronized是关键字内部实现是JVM内部通过C实现的ReentrantLock是标准库的类Java2synchronized是通过代码块控制加锁解锁ReentrantLock需要lock/unlock方法3ReentrantLock除了lock/unlock方法之外还提供了tryLock方法不会阻塞加锁成功返回true加锁失败返回false,调用者根据返回值决定接下来的操作它还提供了设置超时时间的版本等待时间达到超时时间再返回true/false4ReentrantLock提供了公平锁的实现但它默认是非公平的这样就是公平的5ReentrantLock搭配的等待通知机制是Condition类相比wait notify来说功能更强大一些3.3 信号量 Semaphore含义相当于计数器描述了某种可用资源的个数。申请一个资源计数器就会-1P操作释放一个资源计数器就会1V操作计数器为0如果继续申请就会阻塞等待。所说的PV操作就是这两个但是这两个可能是荷兰语的单词首字母所以英语中找不到。但是可以用acquire表示申请release表示释放。代码示例运行结果申请次数资源个数就会阻塞除非另一个线程进行v操作运行结果作用在操作系统中它能够协调多个进程之间的分配也能够协调多个线程之间的分配。二元信号量信号量的一个特殊情况初始值为1的信号量取值要么是1要么是0。等价于“锁”普通的信号量就相当于锁的更广泛的推广意味着只有第一个线程能进行P操作-1就变为0了第二个线程进行P操作就会阻塞只有第一个线程进行V操作1不是0了之后第二个线程才能进行P操作。如果是普通的N的信号量就意味着有N个资源就可以限制同时有多少个线程来执行某个逻辑。基于信号量来解决线程安全问题的代码运行结果3.4 CountDownLatch使用多线程经常把一个大的任务拆分成多个子任务。使用多线程执行这些子任务从而提高程序的效率。CountDownLatch等待多个子任务执行完毕。那如何衡量多个子任务都完成了呢整个任务都完成了呢1CountDownLatch构造方法指定参数描述拆成了多少个任务比如102每次任务执行完毕之后都调用一次countDown方法当调用10次CountDown说明任务完成了3主线程中调用await方法等待任务执行完毕await这里的a表示all就会返回/阻塞等待代码示例运行结果4 多线程下使用ArrayList1自行加锁推荐2Collections.synchronizedList(new ArrayList)返回的List的各种关键方法都带有synchronized加锁是有代价的相当于一个套壳不推荐3使用CopyOnWriteArrayList不加锁不会产生阻塞推荐CopyOnWrite写时拷贝编程中的一种常见思想方法那什么是写时拷贝呢假设有一个这样的数组在多线程读取的时候一旦某个线程进行写操作比如修改1-100这时就会把修改之前的数组复制一份在复制过程中如果其他线程在读就直接读取旧版本的数据。读取完之后在新的数组修改。复制完毕把引用指向新的数组。虽然复制的过程不是原子的消耗一定的时间但是提供了旧版本的数据不影响其他线程读取引用的赋值是原子的。确保读取过程中要么读到的是旧版数据要么读到的是新版数据不会读到“修改一半”比如原先是1、2、3、4要修改为100200300400写时拷贝就会读到00200300400而不是 100、200、3、4这种的的数据。缺点1要是数组特别大的话就非常低效2如果多个线程同时修改也容易出现问题适合的场景服务器进行重新加载配置的时候5 多线程使用哈希表HashMap线程不安全Hashtable线程安全给各种public方法都加synchronized不推荐ConcurrentHashMap效率更高按照桶级别进行加锁而不是全局加锁能够有效降低锁冲突概率。那什么是桶呢对于哈希表来说如果修改的两个元素在不同的链表上本身就不涉及线程安全问题修改的是不同变量如果修改同一个链表上的两个元素就可能有安全问题了比如把两个元素插入到同一个元素后面相邻。所以可以给每个链表加不同的锁使用每个链表的头结点作为synchronized锁对象针对不同对象加锁不会产生锁竞争不会阻塞这就是锁桶。但是还有一个问题比如修改一个链表上的元素插入一个元素修改另一个链表上的元素也插入元素看似是修改不同的变量但是修改了一个相同的变量size那怎么办呢针对size并不需要加锁而是使用原子类以上是ConcurrentHashMap两个核心优化点锁桶、使用原子类针对size进行维护另外还有一个核心优化点针对哈希扩容的场景化整为零确保每个操作的加锁时间不要太长。我们知道扩容意味着需要更大的数组它把旧的哈希中所有的元素搬运到新的哈希中。如果元素很多的话耗时就会很长。对于单线程HashMap来说那就无所谓了。但要是多线程操作、ConcurrentHashMap那就不行了。假设某个插入操作触发了扩容那就得进行搬运搬运的过程中大范围的修改需要进行更长时间的加锁锁冲突的时间更长。那不就和设置锁桶的目的冲突了吗那如何优化呢策略就是刚刚提到的化整为零既然整个搬运比较耗时那不妨把整个过程拆分成多次来完成节省时间。一旦触发“扩容”不是通过一次put来完成的而是通过多次put/get等操作来完成。OK啦到此结束