ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

多线程带来的风险-线程安全

多线程带来的风险-线程安全 目录1.线程安全的解决手段1.1 sychronized1.2 volatile 关键字1.3 wait和notify1.4 死锁1.4.2 死锁产生的必要条件1.4.2 如何避免死锁2. 单例模式2.1 饿汉式2.2 懒汉式3.指令重排序5. 线程池7. 锁策略7.1 悲观锁与乐观锁7.2 可重入锁与不可重入锁7.3 轻量级锁与重量级锁7.4 自旋锁7.5 公平锁与非公平锁7.6 读写锁8. CAS8.1 CAS 的工作原理8.2 Java 中的 CAS 实现8.3 CAS 的缺点线程调度是随机的是线程安全问题的主要责任。在JAVA中每条语句不一定是原子的其内部可能有许多cpu指令完成这就导致在多线程的情况下对于修改同一变量产生许多问题。1.线程安全的解决手段1.1 sychronized在上一篇也是介绍过这个锁了它可以解决原子性问题而且本身也是一个可重入的锁1.2 volatile 关键字volatile 可以保证内存可见性的问题用来修饰变量。对于内存可见性是由于编译器优化引起的编译器编译的时候自动分析这一部分代码的逻辑保持代码逻辑不变的前提下自动修改代码内容没让代码变得更高效。比如下面一段代码package thread; import java.util.Scanner; public class Demo19 { private volatile static int flag; public static void main(String[] args) { Thread t1new Thread(() -gt; { while (flag0) { } System.out.println(t1执行完毕); }); Thread t2new Thread(() -amp;gt; { Scanner scannernew Scanner(System.in); flagscanner.nextInt(); System.out.println(t2执行完毕); }); t1.start(); t2.start(); } }在线程while操作中站在cpu的角度来看读取load数据flag操作的时间远远大于比较操作在用户去修改flag的值时1秒钟就可能使这个循环执行很多次在修改之前flag不变编译器于是就会做出一个决定把load操作优化掉了之后直接从寄存器或缓存中拿到flag的值提供代码效率。在之后的修改操作后while这里并没有读取到修改之后的值于是循环不会结束。使用volatile之后可以避免这个问题。但volatile 不保证原子性。1.3 wait和notifywait方法可以使当前线程进入阻塞状态搭配notify将其唤醒。wait要做的事1.使当前代码的线程进行等待2.释放当前的锁3.满足一定条件使被唤醒重新尝试获取这个锁wait和notify一般放在锁里面使用,wait搭配while效果更好。package thread; import java.util.Scanner; public class Demo20 { public static void main(String[] args) { Object locker new Object(); Thread t1new Thread(() -gt; { synchronized (locker) { System.out.println(t1执行前); try { locker.wait(); // wait 必须放在锁里使用 释放当前锁进入阻塞状态 等待唤醒重新获取锁 } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println(t1执行后); } }); Thread t2new Thread(() -amp;gt; { synchronized (locker) { System.out.println(t2执行前); Scanner scannernew Scanner(System.in); System.out.println(随意输入唤醒t1); scanner.next(); locker.notify(); // notify 也要放在锁里 System.out.println(t2执行后); } }); t1.start(); t2.start(); } }如果wait有多个那么使用notify它将随机唤醒一个wait。此处还有一个方法notifyAll这个方法可以把所有的wait都唤醒。1.4 死锁死锁有这几种情况1.一个线程一把锁连续加锁两次2.两个线程两把锁相互获取对方的锁3. m个线程n把锁 例如哲学家就餐问题示例package thread; // 死锁案例 public class Demo18 { public static void main(String[] args) { Object lock1new Object(); Object lock2new Object(); Thread t1new Thread(() - { synchronized (lock1) { System.out.println(lock1); synchronized (lock2) { System.out.println(t1); } } }); Thread t2 new Thread(() -gt; { synchronized (lock2) { System.out.println(lock2); synchronized (lock1) { System.out.println(t2); } } }); t1.start(); t2.start(); } }这是典型的两把锁。互相获取对方的锁。线程1想要解锁就需要拿到lock2线程2需要解锁要拿到lock1这样谁都拿不到谁。1.4.2 死锁产生的必要条件1.锁是互斥的2.锁不可被抢占3.请求和保持4.循环等待/环路等待死锁产生的四个必要条件必须同时满足才会发生死锁互斥条件一个资源同一时刻只能被一个线程占用其他线程只能等待。不可抢占条件线程已持有的资源不能被其他线程强行抢走只能由持有者主动释放。请求与保持条件线程已经持有了至少一个资源同时又去请求新的资源而新资源被其他线程占用此时该线程不会释放自己已持有的资源。循环等待条件存在一个线程等待环比如线程A等待线程B持有的资源线程B又等待线程A持有的资源形成环路导致谁都无法推进。只要破坏上述任意一个条件死锁就可以被避免。例如一次性申请所有资源破坏请求与保持、给锁加超时时间破坏不可抢占、按固定顺序加锁破坏循环等待。1.4.2 如何避免死锁既然死锁需要四个必要条件同时满足才会发生那么只要破坏其中任意一个条件就可以有效避免死锁。常见的避免策略有以下几种破坏互斥条件尽量使用无锁编程例如使用 CASCompare And Swap原子操作、ThreadLocal 等从根源上避免锁的竞争。破坏不可抢占条件给锁加超时时间使用tryLock获取锁如果在一定时间内获取不到就主动放弃避免无限等待。破坏请求与保持条件一次性申请所有需要的资源或者先释放已持有的锁再去申请新的锁避免持有锁的同时还去请求其他锁。破坏循环等待条件给所有锁规定一个固定的加锁顺序所有线程都按照这个顺序去加锁就不会形成环路等待。在实际开发中最常用也最有效的方式是按固定顺序加锁。例如上面的死锁案例只要让 t1 和 t2 都先加 lock1 再加 lock2就不会出现互相等待对方锁的情况死锁自然就被避免了。2. 单例模式单例模式有两种写法2.1 饿汉式// 单例模式 饿汉式 class Singleton { private static Singleton singletonnew Singleton(); private Singleton() { } public static Singleton createInstance() { return singleton; } }2.2 懒汉式class Singleton1 { private volatile static Singleton1 instance; private static Object lockernew Object(); public static Singleton1 createInstance() { if (instancenull) { synchronized (locker) { if (instancenull) { instance new Singleton1(); // 此处可能发生指令重排序 // 可能先引入分配内存空间的首地址赋值给变量在初始化 // 导致其他字段没有初始化调用其成员方法时访问字段为默认值 } } } return instance; } private Singleton1() { } }这是多线程下比较安全的写法饿汉式只进行读操作不涉及线程安全懒汉式涉及比较操作需要保证其线程安全问题。注意 下面是解决懒汉式线程安全的解决方式1.加锁保证原子性2.双重判断提高效率3. volatile 解决指令重排序问题3.指令重排序上述第二个判断这里就可能涉及指令重排序问题指令重排序它也是编译器优化的一种手段通过调整指令执行的顺序来提高代码的执行效率。在上述代码中实例一个对象时会调用构造方法其中就会涉及到很多指令简单来说就是这三个步骤1.分配内存空间2.针对空间进行初始化3.内存空间的首地址赋值给引用变量编译器进行优化后可能按照这样的顺序执行1-3-2这样就导致当先执行3步骤其他线程去使用这个类创建对象时可能这个时候拿到了实例对象但是没有进行初始化导致其他字段为默认值当其他线程再去调用这个类的一些方法访问字段时就可能读到默认值。使用volatile可以解决指令重排序的问题。这里还有一个其他例子class MysqlJdbc { private volatile static DataSource dataSource; private MysqlJdbc() { } public static DataSource createInstance() { if (dataSourcenull) { synchronized (MysqlJdbc.class) { if (dataSourcenull) { // 这样写虽然解决了指令重排序但set的数据没有完全初始化导致其他线程可能没有读到账户信息 dataSourcenew MysqlDataSource(); ((MysqlDataSource)dataSource).setUrl(jdbc:mysql://127.0.0.1:3306/java177?characterEncodingutf8amp;useSSlfalse); ((MysqlDataSource)dataSource).setUser(root); ((MysqlDataSource)dataSource).setPassword(*********); } } } return dataSource; } }这个代码虽然解决了指令重排序问题但它先进行了创建实例对象再分配数据导致其他线程在用这个单例模式创建对象时可能还没有分配数据这个线程已经判断这个实例不为空直接返回了一个对象导致数据没有给到这个引用。这里可以改变一下代码顺序解决这个问题先分配数据再赋值引用。DataSource datanew MysqlDataSource(); ((MysqlDataSource)data).setUrl(jdbc:mysql://127.0.0.1:3306/java177?characterEncodingutf8useSSlfalse); ((MysqlDataSource)data).setUser(root); ((MysqlDataSource)data).setPassword(*********); dataSourcedata;5. 线程池线程池是一种基于池化思想的多线程管理方式。它预先创建一定数量的线程放入一个池子中统一管理当有任务需要执行时就从池中取出一个空闲线程去执行任务任务执行完毕后线程并不会销毁而是回到池中等待下一个任务。线程池主要解决两个问题降低资源消耗频繁创建和销毁线程会带来较大的系统开销线程池复用已有线程避免了反复创建销毁带来的性能损耗。提高响应速度任务到达时无需等待线程创建直接从池中获取空闲线程即可立即执行。在 Java 中最常用的线程池是通过Executors工具类或ThreadPoolExecutor来创建。例如public class Demo27 { public static void main(String[] args) { ExecutorService executorService Executors.newCachedThreadPool(); //ExecutorService executorService Executors.newFixedThreadPool(4); for (int i 0; i 1000; i) { int idi; executorService.submit(() - { Thread thread Thread.currentThread(); System.out.println(hello thread.getName() id); }); } } }线程池也可以直接使用ThreadPoolExecutor直接创建对象线程池的核心参数包括核心线程数、最大线程数、空闲线程存活时间、任务队列以及拒绝策略等合理配置这些参数可以让线程池在性能和资源占用之间达到平衡。构造方法有四种最后一个参数是最全的我们只讨论最后一个。最大线程数包括核心线程数非核心线程数核心线程是线程池一创建就有的非核心线程是池内没有空闲的线程执行任务而自动创建的线程。我们具体关注一下拒绝策略当线程池的任务队列已满且线程数已经达到最大线程数时新提交的任务就会触发拒绝策略。Java 提供了四种内置的拒绝策略都实现了RejectedExecutionHandler接口AbortPolicy默认直接抛出RejectedExecutionException异常终止任务的提交。这是最常用的策略能及时暴露问题。CallerRunsPolicy不丢弃任务而是由提交任务的线程自己来执行该任务。这样既不会丢失任务也能起到一定的限流作用。DiscardPolicy直接丢弃新提交的任务不抛出任何异常。适合对任务丢失不敏感的场景。DiscardOldestPolicy丢弃队列中最旧的一个任务然后重新尝试提交当前任务。适合允许丢弃部分旧任务的场景。7. 锁策略锁是解决线程安全问题的重要手段Java 中根据不同的维度可以把锁分成很多种。下面从几个常见维度来介绍这些锁的概念和区别。7.1 悲观锁与乐观锁这是从对待并发冲突的态度来划分的。悲观锁总是假设最坏的情况认为每次访问共享资源时都可能发生冲突所以在操作数据之前必须先加锁只有拿到锁才能操作操作完再释放锁。典型的实现就是synchronized和ReentrantLock。它的优点是安全性高缺点是加锁、解锁会带来性能开销而且容易引发阻塞。乐观锁总是假设最好的情况认为并发冲突很少发生所以不加锁而是在更新数据时通过版本号或 CASCompare And Swap机制来判断数据是否被其他线程修改过。如果发现被修改了就重试或报错。典型的实现是AtomicInteger等原子类。它的优点是并发性能高缺点是如果冲突频繁重试次数多反而会降低效率。7.2 可重入锁与不可重入锁这是从同一个线程能否重复获取同一把锁来划分的。可重入锁同一个线程可以多次获取同一把锁而不会把自己阻塞住。比如一个线程已经拿到了锁在锁内部又调用了一个需要同一把锁的方法此时可以直接进入不需要重新等待。Java 中的synchronized和ReentrantLock都是可重入锁。可重入锁内部维护了一个计数器每进入一次加一每退出一次减一减到 0 才真正释放锁。不可重入锁同一个线程不能重复获取同一把锁如果线程已经持有锁再次尝试获取时就会把自己阻塞住造成死锁。这种锁在 Java 标准库中并不常见一般需要自己实现。7.3 轻量级锁与重量级锁这是从锁的实现机制和开销大小来划分的主要针对synchronized的优化过程。轻量级锁当锁竞争不激烈时JVM 会使用 CAS 操作在对象头中记录锁信息不需要线程阻塞和唤醒开销较小适合锁持有时间短的场景。重量级锁当锁竞争激烈时JVM 会把锁升级为重量级锁依赖操作系统底层的互斥量Mutex来实现线程获取不到锁时会进入阻塞状态涉及用户态和内核态的切换开销较大。在 JDK 1.6 之后synchronized引入了锁升级机制无锁 → 偏向锁 → 轻量级锁 → 重量级锁根据竞争激烈程度动态调整尽量用更轻量的方式保证线程安全。7.4 自旋锁自旋锁是一种忙等待的锁。当线程获取不到锁时它不会立刻进入阻塞状态而是在原地循环自旋不断尝试获取锁直到拿到锁为止。自旋的好处是避免了线程阻塞和唤醒带来的上下文切换开销适合锁持有时间很短的场景坏处是如果锁被持有时间较长自旋会白白消耗 CPU 资源。Java 中AtomicInteger等原子类的 CAS 操作本质上就是一种自旋的思想。7.5 公平锁与非公平锁这是从线程获取锁的顺序来划分的。公平锁多个线程按照申请锁的顺序依次获取锁先来先得不会出现插队现象。优点是公平缺点是性能相对较低因为需要维护一个等待队列。在ReentrantLock中可以通过构造方法传入true来创建公平锁。非公平锁线程获取锁时不保证按申请顺序新来的线程可能直接抢到锁造成插队。优点是吞吐量更高缺点是可能造成某些线程长时间等待饥饿。synchronized和默认的ReentrantLock都是非公平锁。7.6 读写锁读写锁把锁分成了读锁和写锁两种适用于读多写少的场景。它的规则是读锁之间可以共享多个线程可以同时读写锁是独占的写的时候其他线程既不能读也不能写读锁和写锁互斥。Java 中ReentrantReadWriteLock就是读写锁的实现。相比普通的互斥锁读写锁在读多写少的场景下能显著提高并发性能。8. CASCAS 的全称是 Compare And Swap即「比较并交换」是一种无锁的原子操作。它通过硬件指令如 CPU 的 cmpxchg 指令来保证「比较」和「交换」这两个步骤的原子性是乐观锁的核心实现方式。8.1 CAS 的工作原理CAS 操作包含三个参数内存地址 V要操作的共享变量的内存位置。期望值 A操作前认为该变量当前的值。新值 B希望更新成的新值。执行过程是只有当内存地址 V 中的值等于期望值 A 时才把 V 的值更新为新值 B如果 V 的值不等于 A说明变量已经被其他线程修改过本次操作失败不进行任何修改。整个过程由 CPU 指令保证原子性不需要加锁。8.2 Java 中的 CAS 实现在 Java 中CAS 主要封装在java.util.concurrent.atomic包下的原子类中例如AtomicInteger、AtomicLong、AtomicReference等。以AtomicInteger为例import java.util.concurrent.atomic.AtomicInteger; public class DemoCAS { public static void main(String[] args) { AtomicInteger count new AtomicInteger(0); // 期望值是 0如果当前值是 0 就更新为 1 boolean success count.compareAndSet(0, 1); System.out.println(第一次CAS结果: success , 当前值: count.get()); // 期望值是 0但当前值已经是 1CAS 失败 boolean fail count.compareAndSet(0, 2); System.out.println(第二次CAS结果: fail , 当前值: count.get()); } }运行结果第一次 CAS 成功把 0 更新为 1第二次 CAS 因为当前值已经是 1不等于期望值 0所以失败值保持不变。8.3 CAS 的缺点CAS 虽然高效但也有几个明显的缺点ABA 问题变量从 A 变成 B 又变回 ACAS 会认为它没有被修改过但实际上已经被其他线程改过。解决方式是使用带版本号的AtomicStampedReference。自旋开销大如果竞争激烈CAS 会一直自旋重试白白消耗 CPU 资源。解决方式是结合锁升级机制竞争激烈时升级为重量级锁。只能保证一个变量的原子性CAS 只能针对单个变量操作无法同时保证多个变量的原子性。解决方式是使用AtomicReference把多个变量封装成一个对象。总的来说CAS 是一种高效的无锁并发方案适合竞争不激烈的场景在竞争激烈时需要结合锁或其他机制来保证性能和正确性。
返回列表