当前位置: 首页 > 科技观察

这些线程安全的坑你在工作中踩过吗?

时间:2023-03-19 17:58:00 科技观察

我们知道多线程可以并发处理多个任务,有效提升复杂应用的性能,在实际开发中起到非常重要的作用。但是,使用多线程也带来了很多风险,线程带来的问题在测试中往往很难发现,上线时会造成重大的失败和损失。下面我将结合几个实际案例,帮助大家在工作中避免这些问题。使用多线程的问题很大程度上是由于多个线程对同一个变量的操作权限,以及不同线程之间执行顺序的不确定性《Java并发编程实战》本书提到了三种多线程问题:SecurityProblems、livenessproblems和性能问题安全问题比如有一个很简单的自减函数操作,如下:publicintdecrement(){return--count;//countinitialinventoryis10}在单线程环境下,这个方法可以正常工作,但是在多线程环境下,会导致错误的结果--count看似是一个操作,但实际上它包含三个步骤(读取-修改-写入):读取count的值并减1。最后,将计算结果赋值给count。下图是一个错误的执行过程。当两个线程1和2同时执行该方法时,读取count的值为10,最终返回结果为9。;表示可能有两个人购买了商品,但是库存有只减少了1,这对于真实的生产环境来说是不可接受的。像上面的例子,由于执行时机不当导致结果不正确的情况是非常严重的情况。一个常见的并发安全问题称为竞争条件。导致竞争条件的decrement()方法称为临界区。为了避免这个问题,需要保证读-修改-写操作的原子性。在Java中,实现方式有很多,比如使用synchronize内置锁或者ReentrantLock显式锁锁定机制,使用线程安全的原子类,采用CAS方式等。活跃度问题是指某个操作由于阻塞或循环而无法继续。最典型的有死锁、活锁和饥饿死锁三种。最常见的活动问题是死锁。死锁是指多个线程互相等待获取锁,不会释放自己拥有的锁,就会造成阻塞,使这些线程无法运行。这是一个僵局。往往是由于锁机制使用不当,线程间的执行顺序不可预测造成的。如何防止死锁1.尽量保证加锁顺序相同。比如有A、B、C三把锁,Thread1的加锁顺序是A、B、C。Thread2的加锁顺序是A,C,这样就不会出现死锁。如果Thread2的加锁顺序是B、A或者C、A,顺序不一致,就会出现死锁问题。2、尽量使用超时放弃机制。Lock接口提供了tryLock(longtime,TimeUnitunit)方法,可以在固定的时间内等待一个锁,这样线程可以在超时前主动释放所有已经获取的锁。它可以避免死锁问题。活锁和死锁很相似,程序等不及结果。但是和死锁相比,活锁是活的。这是什么意思?因为正在运行的线程没有被阻塞,所以总是Hunger,指的是线程在需要某些资源,尤其是CPU资源的时候,不能一直运行的问题。Java中有线程优先级的概念。Java中的优先级分为1到10,1最低,10最高。如果我们将一个线程的优先级设置为1,也就是最低的优先级,在这种情况下,线程可能一直分配不到CPU资源,导致长时间无法运行。性能问题线程本身的创建和线程之间的切换会消耗资源。如果线程创建频繁或者CPU花在线程调度上的时间远远多于线程运行时间,那么使用线程就得不偿失了,甚至可能导致CPU负载过高或者OOM后果说明线程不安全案例1使用线程不安全的集合(ArrayList、HashMap等)要进行同步,最好使用线程安全的并发集合。,ConcurrentModificationException可能会被抛出,这通常被称为fail-fast机制。下面的例子模拟多个线程同时操作ArrayList。线程t1遍历列表并打印它。线程t2将元素添加到列表中。Listlist=newArrayList<>();list.add(0);list.add(1);list.add(2);//list:[0,1,2]System.out.println(list);//线程t1遍历printlistThreadt1=newThread(()->{for(inti:list){System.out.println(i);}});//线程t2向列表添加元素Threadt2=newThread(()->{for(inti=3;i<6;i++){list.add(i);}});t1.start();t2.start();进入抛出异常的ArrayList源码可以看到,遍历ArrayList是通过内部实现的iterator完成调用iterator的next()方法获取下一个元素时,会先检查modCount是否和expectedModCount通过checkForComodification()方法相等,不相等则抛出ConcurrentModificationException。ModCount是ArrayList的一个属性,表示集合结构被修改的次数(链表长度变化的次数),每次调用add或remove等方法都会给modCount加1。=modCount)所以当其他线程添加或删除集合元素时,modCount会增加,然后集合如果expectedModCount不等于modCount,将抛出异常。使用锁机制操作线程不安全的集合类Listlist=newArrayList<>();list.add(0);list.add(1);list。add(2);System.out.println(list);//线程t1遍历并打印listThreadt1=newThread(()->{synchronized(list){//使用synchronized关键字for(inti:list){System.out.println(i);}}});//线程t2向列表添加元素Threadt2=newThread(()->{synchronized(list){for(inti=3;i<6;i++){list.add(i);System.out.println(list);}}});t1.start();t2.start();如上面代码,使用synchronized关键字锁定对链表的操作,不会抛出异常但是,使用synchronized相当于序列化锁定的代码块。它在性能方面没有优势。推荐使用线程安全的并发工具类。JDK1.5新增了很多线程安全的工具类供使用,比如CopyOnWriteArrayList、ConcurrentHashMap等,在容器的日常开发中推荐使用这些工具类来实现多线程编程。情况2:不要将SimpleDateFormat用作全局变量。SimpleDateFormat实际上是一个线程不安全的类。根本原因是SimpleDateFormat的内部实现没有对一些共享变量进行操作。同步publicstaticfinalSimpleDateFormatSDF_FORMAT=newSimpleDateFormat("yyyy-MM-ddHH:mm:ss");publicstaticvoidmain(String[]args){//两个线程同时调用SimpleDateFormat.parse方法Threadt1=newThread(()->{try{Datedate1=SDF_FORMAT.parse("2019-12-0917:04:32");}catch(ParseExceptione){e.printStackTrace();}});Threadt2=newThread(()->{try{Datedate2=SDF_FORMAT.parse("2019-12-0917:43:32");}catch(ParseExceptione){e.printStackTrace();}});t1.start();t2.start();}推荐使用SimpleDateFormat作为局部变量,或者最简单的方法是配合ThreadLocal使用SimpleDateFormat作为一个局部变量,但是如果在for循环中使用,会创建很多实例,可以优化使用ThreadLocal//InitializepublicstaticfinalThreadLocalSDF_FORMAT=newThreadLocal(){@OverrideprotectedSimpleDateFormatinitialValue(){returnnewSimpleDateFormat("yyyy-MM-ddHH:mm:ss");}};//调用Datedate=SDF_FORMAT.get().parse(wedDate);推荐使用Java8的LocalDateTime和DateTimeFormatterLocalDateTime和DateTimeFormatter是Java8引入的新特性,不仅线程安全,而且使用起来更方便。实际开发中建议使用LocalDateTime和DateTimeFormatter而不是Calendar和SimpleDateFormatDateTimeFormatter。.now();System.out.println(formatter.format(time));正确的释放锁假设有这么一段伪代码:Locklock=newReentrantLock();...try{lock.tryLock(timeout,TimeUnit.MILLISECONDS)//业务逻辑}catch(Exceptione){//错误日志//抛出异常或直接返回}finally{//业务逻辑lock.unlock();}...这段代码释放锁中在finallycodeblock之前,执行了一段业务逻辑。如果不幸这个逻辑中的依赖服务不可用,占用锁的线程无法成功释放锁,就会导致其他线程因为无法获取到锁而阻塞,最终导致线程池满。所以在锁之前释放;在finally子句中,应该只有一些处理释放当前线程占用的资源(如锁、IO流等)。还有在获取锁的时候设置一个合理的超时时间,防止线程因为获取不到锁而被阻塞,您可以设置超时时间。当锁超时时,线程可以抛出异常或返回错误状态码。超时时间设置也要合理,不能太长,要长于被锁定业务逻辑的执行时间。正确使用线程池案例1不要将线程池作为局部变量使用publicvoidrequest(Listids){for(inti=0;i