尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

【Java SE】多线程(四):认识锁策略、CAS机制与JUC

【Java SE】多线程(四):认识锁策略、CAS机制与JUC 文章目录一、常见锁策略1. 乐观锁 vs 悲观锁2. 轻量级锁 vs 重量级锁3. 自旋锁 vs 挂起等待锁4. 普通互斥锁 vs 读写锁5. 可重入锁 vs 不可重入锁6. 公平锁 vs 非公平锁7. synchronized锁策略详解1锁升级2 锁消除3 锁粗化二、CAS1. 工作原理2. 典型应用原子类getAndIncrement工作原理自旋锁3. ABA问题三、JUC 常用核心类java.util.concurrent1. Callable FutureTask2. ReentrantLock可重入锁3. Semaphore 信号量4. CountDownLatch四、线程安全集合类1. List 集合2. 阻塞队列3. Map 集合一、常见锁策略锁是解决多线程并发安全的核心主流锁策略适用于各类编程语言Java中synchronized、ReentrantLock均基于这些思想实现。1. 乐观锁 vs 悲观锁乐观锁默认冲突概率低不加锁直接访问仅在数据提交时检测冲突冲突则失败重试适合低并发场景。悲观锁默认并发冲突概率高访问数据前先加锁其他线程阻塞等待适合锁竞争激烈的场景。JDK1.8的synchronized初始为乐观锁锁竞争频繁时自动转为悲观锁。2. 轻量级锁 vs 重量级锁二者区别在于是否依赖操作系统内核互斥锁mutex轻量级锁适用于乐观的场景下。尽量在用户态完成操作减少用户态/内核态切换开销更小更高效。重量级锁适用悲观的场景。重度依赖OS的mutex开销更大更低效synchronized无竞争时为轻量级锁竞争加剧后膨胀为重量级锁。3. 自旋锁 vs 挂起等待锁自旋锁轻量级锁的典型实现。获取锁失败后不阻塞循环重试抢锁也就是忙等。因为在乐观场景下出现锁竞争的概率比较小即使出现锁竞争短时间内很快就能拿到锁。优点锁释放后可立即获取响应快缺点锁持有时间久会持续消耗CPU。挂起等待锁重量级锁的典型实现。抢锁失败后线程阻塞、释放CPU由操作系统唤醒无CPU消耗但唤醒延迟高。synchronized轻量级锁底层采用自适应自旋锁。4. 普通互斥锁 vs 读写锁1. 普通互斥锁像synchronized这样只涉及加锁和解锁两种操作2. 读写锁分为读锁和写锁。多个线程多一个数据是线程安全的多个线程读取一个或多个线程修改数据就会涉及到线程安全问题。在大部分读操作少部分写操作的场景下使用synchronized就会有严重的锁冲突。而读写锁确保读锁和读锁之间不互斥读锁与写锁写锁与写锁之间互斥Java提供了读锁ReentrantReadWriteLock.ReadLock和写锁ReentrantReadWriteLock.WriteLock都属于ReentrantReadWriteLock的内部类synchronized并非读写锁。5. 可重入锁 vs 不可重入锁可重入锁同一线程可多次获取同一把锁不会造成死锁synchronized、ReentrantLock均为可重入锁通过线程标识计数器实现。不可重入锁同一线程重复加锁会阻塞自身引发死锁Linux原生mutex就是不可重入锁。可重入锁需要记录当前是哪个线程拿到了锁并通过计数器记录当前加锁了多少次在合适的时候进行解锁。6. 公平锁 vs 非公平锁公平锁遵循先来后到线程按等待顺序获取锁需额外结构记录排队顺序性能略低。非公平锁不保证顺序新线程可直接抢占锁减少线程切换默认锁均为非公平锁。synchronized是非公平锁ReentrantLock可手动开启公平模式。7. synchronized锁策略详解1锁升级无锁没有synchronized修饰的代码块处于无锁的状态偏向锁刚进入synchronized代码块内不会立即加锁而是做一个简单的标记。如果没有其他线程来竞争这个锁最终当前线程执行完毕解锁代码也只是简单的清楚标记。这个标记非常轻量相比加锁解锁效率高很多。轻量级锁拿到偏向锁的线程运行过程中遇到了其他线程尝试抢这把锁就会升级为轻量级锁。重量级锁JVM发现这把锁竞争非常激烈就会升级成重量级锁。2 锁消除编译器会判定当前的代码逻辑是否真的需要锁。如果确实不需要这把锁就会会把synchronized去掉。3 锁粗化锁的粒度加锁和解锁之间包含代码越多就认为锁的粒度越粗这里代码多不仅指行数还要考虑实际执行的指令和时间如果一块代码反复对细粒度的代码进行加锁每一次解锁后再加锁都会涉及到竞争编译器就可能优化成粗粒度的加锁从而提高效率。二、CASCAS就是指比较和交换(Compare and Swap)是CPU的一条指令。1. 工作原理伪代码booleanCAS(address,expectValue,swapValue){if(addressexpectValue){addressswapValue;returntrue;}returnfalse;}address表示内存地址expectValue表示寄存器1的值swapValue表示寄存器2的值。比较内存值与寄存器1是否相等相等则把内存中的值和寄存器2进行交换。一般情况只关心交换后内存中的值不关心寄存器2的值此处可以把交换的操作理解成赋值。本质上还是交换基于交换实现了赋值。通过上面的分析来看虽然逻辑略显复杂但是CAS是CPU的一条指令而CPU是以指令为单位工作也就是说CAS具有原子性的特性2. 典型应用CAS封装CPU操作系统JVMJava 标准库Java程序员原子类java.util.concurrent.atomic包下所有类都是原子类基于CAS实现无锁原子操作避免加锁操作性能更高。AtomicInteger原子类int常用方法功能说明get()获取当前值set(int val)直接设置值incrementAndGet()自增1返回新值类似igetAndIncrement()自增1返回旧值 类似idecrementAndGet()自减1返回新值addAndGet(int delta)加上增量返回新值AtomicBoolean原子类boolean常用方法功能说明AtomicBooleanget()、set(boolean val)compareAndSet(boolean expect, boolean update)CAS更新布尔getAndIncrement工作原理伪代码classMyAtomicInteger{privateintvalue;publicintgetAndIncrement(){intoldValuevalue;//理解成寄存器把内存存到寄存器中while(!CAS(value,oldValue,oldValue1)){oldValuevalue;}returnoldValue;}}使用oldValue记录value相当于把内存中的值存到寄存器中调用CAS如果value和oldValue相等则让oldValue1赋值给value此时CAS返回true循环条件不成立就结束运行调用CAS如果value和oldValue不相等CAS返回false进入循环更新寄存器中的结果。即使上述代码存在线程切换进行自增之前先判断当前寄存器中的值是否准确不过不正确就会重新读取。自旋锁伪代码publicclassSpinLock{//如果是null 说明锁是空闲的//如果非null说明已经被其他线程占用privateThreadownernull;publicvoidlock{//通过CAS判断当前这锁是否被某个线程持有//如果被占有就自旋等待//如果没有就尝试把owner设为当前尝试加锁的线程while(CAS(this.owner,null,Thread.currentThread())){\}}publicvoidunlock(){//单个的赋值操作就是原子的this.ownernull;}}由于lock循环体是空的整个循环过程非常快也就是忙等一旦其他线程释放了锁就可以立即获取到锁3. ABA问题问题变量从A→B→ACAS无法识别数据被修改过导致逻辑异常。解决方案引入版本号每次修改数据版本号自增CAS同时校验数据值版本号Java中使用AtomicStampedReference实现带版本的CAS。三、JUC 常用核心类java.util.concurrentJUC包提供了大量并发工具弥补synchronized灵活性不足的问题是多线程开发主力。1. Callable FutureTaskCallable与Runnable类似都可以描述线程执行的任务。Runnable没有返回值不能抛出异常Callable有泛型返回值可以抛出异常用于线程执行任务后获取结果。Callable需要搭配FutureTask使用FutureTask负责包装Callable任务交给Thread执行Callable的返回结果交给FuturnTask可以通过get()方法获取。get()就是获取到FutureTask的返回值来自Callable的call方法。如果线程执行完毕就可以拿到结果否则get()就会阻塞。示例代码publicclassdemo33{publicstaticvoidmain(String[]args)throwsExecutionException,InterruptedException{CallableIntegercallablenewCallableInteger(){OverridepublicIntegercall()throwsException{intresult0;for(inti0;i2000;i){result1;}returnresult;}};FutureTaskIntegerfutureTasknewFutureTaskInteger(callable);ThreadtnewThread(futureTask);t.start();System.out.println(futureTask.get());}}创建线程的写法汇总继承Thread类定义单独的类或匿名内部类实现Runnable定义单独的类或匿名内部类lambda表达式实现Callable定义单独的类或匿名内部类线程池ThreadFactory2. ReentrantLock可重入锁与synchronized功能类似属于显式锁灵活性更强二者对比synchronized是关键字、JVM隐式实现自动释放锁ReentrantLock是Java类需手动调用lock()和unlock()。synchronized抢锁失败会永久等待ReentrantLock支持tryLock()超时放弃。ReentrantLock默认是非公平锁可实现公平锁ReentrantLock支持Condition精准唤醒指定线程相比wait/notify 功能更强大。使用规范lock()放在try外unlock()放在finally中保证锁一定释放。3. Semaphore 信号量本质是计数器表示“可用资源的个数”用来控制并发访问资源的线程数量实现限流、资源池场景。可以把信号量理解成停车场的指示牌当驶入车辆可用空位就会-1驶出车辆可用空位1P操作申请资源可用资源为空时阻塞等待V操作释放资源PV操作都是原子的可以在多线程环境下直接使用特殊情况信号量初始值为1取值要么是1要么是0称为二元信号量等价于锁。4. CountDownLatch多线程可以把一个大任务拆成多个子任务CountDownLatch可以等待一组线程全部执行完毕后再继续主线程类似“倒计时”。构造方法中指定参数描述拆成了多少个任务每个任务执行完毕之后调用一次countDown方法计数器-1主线程中调用await()等待所有任务执行完毕.代码示例publicclassdemo36{publicstaticvoidmain(String[]args)throwsInterruptedException{//十个子任务CountDownLatchcountDownLatchnewCountDownLatch(10);ExecutorServiceexecutorServiceExecutors.newFixedThreadPool(4);for(inti0;i10;i){intidi;executorService.submit(()-{System.out.println(子任务开始执行id);try{Thread.sleep(1000);}catch(InterruptedExceptione){thrownewRuntimeException(e);}System.out.println(子任务结束执行id);countDownLatch.countDown();});}countDownLatch.await();System.out.println(所有任务执行完毕);executorService.shutdown();}}四、线程安全集合类传统集合ArrayList、HashMap线程不安全多线程环境需使用专用安全集合1. List 集合Vector/Stack古老线程安全类全局加锁性能差。Collections.synchronizedList基于synchronized封装全方法加锁性能略差。CopyOnWriteArrayList写时复制读不加锁、写时复制新容器适合读多写少场景缺点是内存占用高、数据弱一致性不适合多线程修改的情况大多数情况还是需要手动上锁自行判断哪些操作需要确保原子性。2. 阻塞队列常用于线程池、生产者-消费者模型ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue。3. Map 集合Hashtable对全局对象加锁也就是说任意两个线程访问不同的元素都要竞争。所有方法串行执行并发效率极低key/value不允许为null。ConcurrentHashMap主流线程安全哈希表优化点锁哈希桶也即是链表的头节点大幅降低锁竞争读操作无锁利用volatile保证可见性写操作加synchronized扩容多线程协作完成效率极高key不允许为null。对于哈希表的长度size使用原子类维护。
返回列表