当前位置: 首页 > 后端技术 > Java

Java开发中多线程的基本概念及如何避坑

时间:2023-04-01 13:46:36 Java

1.多线程基本概念1.1轻量级进程在JVM中,一个线程实际上就是一个轻量级进程(LWP)。所谓轻量级进程,其实就是用户进程提供的一组调用系统内核的接口。事实上,java训练它还调用了一个较低级别的内核线程(KLT)。实际上,JVM的线程创建、销毁和调度都依赖于操作系统。如果你查看Thread类中的多个函数,你会发现很多都是原生的,直接调用底层操作系统的函数。下图是Linux上JVM的一个简单线程模型。可以看出,不同线程在切换时,会频繁的进行用户态和内核态的状态转换。这种切换的代价比较大,也就是我们通常所说的上下文切换(ContextSwitch)。1.2JMM在介绍线程同步之前,我们需要介绍一个新名词,它就是JVM内存模型JMM。JMM并不是指堆、元空间等内存的划分。是一个完全不同的概念,指的是与线程相关的Java运行时线程内存模型。由于Java代码在执行时很多指令都不是原子的,如果这些值的执行顺序放错了,就会得到不同的结果。例如i++的action可以翻译成如下字节码。getfield//字段值:Iiconst_1iaddputfield//字段值:I这只是在代码层面。如果在CPU的每个核心上加上各级缓存,这个执行过程会变得更加微妙。如果我们想在执行完i++之后再执行i--,仅靠基本的字节码指令是做不到的。我们需要一些同步方法。上图是JMM的内存模型,分为主内存(MainMemory)和工作内存(WorkingMemory)两种。我们通常在Thread中操作这些变量,其实就是操作的主内存的一份副本。修改后需要重新刷入主存,让其他线程知道这些变化。1.3Java中常用的线程同步方法为了完成JMM的操作,完成线程间的变量同步,Java提供了很多同步方法。在Java的基类Object中,提供了wait和notify原语来完成monitor之间的同步。但是我们在业务编程中很少遇到这种通过使用synchronized来同步方法,或者在concurrent包中使用可重入锁来锁定一个对象来完成代码块同步的操作。这套锁建立在AQS之上。使用volatile轻量级同步关键字实现变量实时可见。使用Atomic系列完成自增自减。使用ThreadLocal线程局部变量实现线程关闭。使用各种并发包。用于实现生产者消费者的工具,例如LinkedBlockingQueue。本质是AQS利用Thread的join和各种await方法来完成并发任务的顺序执行。从上面的描述中,我们可以看出多线程编程需要学习的东西太多了。幸运的是,虽然同步的方法有很多种,但我们创建线程的方法只有几种。第一类是线程类。大家都知道有两种方法可以实现。第一种是继承Thread重写它的run方法;二是实现Runnable接口,实现其run方法;第三种创建线程的方法是通过线程池。其实最终只有一种启动方式,那就是Thread。线程池和Runnable不过是封装的快捷方式。多线程这么复杂,这么容易出问题,所以有常见的问题,怎么避免呢?下面,我将介绍10个高频坑并给出解决方案。2.陷阱指南2.1。线程池炸机首先说一个非常非常低级的多线程错误,后果很严重。通常,我们通过三种方式创建线程:Thread、Runnable和线程池。随着Java1.8的流行,现在最常用的是线程池方式。有一次,我们的线上服务器卡顿了,连远程ssh都登录不上了,无奈只好重启。我们已经看到这种情况会在启动应用程序后的几分钟内发生。最后定位了几行讽刺代码。一个不熟悉多线程的同学用线程池异步处理消息。通常,我们会将线程池作为类的静态变量,或者成员变量。但是这位同学把它放在了方法里面。也就是说,每当有请求到来时,都会创建一个新的线程池。当请求量增加时,系统资源被耗尽,最终导致整机卡顿。voidrealJob(){ThreadPoolExecutorexe=newThreadPoolExecutor(...);exe.submit(newRunnable(){...})}如何避免这种问题?只能通过代码审查。所以,多线程相关的代码,哪怕是很简单的同步关键字,都必须要有经验的人来写。即使没有这种情况,也应该非常仔细地审查代码。2.2.应该关闭锁与synchronized关键字加的独占锁相比,concurrent包中的Lock提供了更多的灵活性。可以根据需要选择公平锁和非公平锁、读锁和写锁。但是Lock用完之后必须关闭,也就是lock和unlock必须成对出现,否则很容易泄露锁,导致其他线程永远拿不到锁。如下面的代码,我们调用lock之后,出现了异常,try中的执行逻辑会被打断,unlock就永远没有机会执行了。在这种情况下,线程获得的锁资源将永远不会被释放。privatefinalLocklock=newReentrantLock();voiddoJob(){try{lock.lock();//发生异常lock.unlock();}catch(Exceptione){}}正确的做法是unlock函数,放在一个finally块中,保证一直可以执行。由于lock也是一个普通对象,所以可以作为函数的参数。如果在函数之间来回传递锁,也会出现时序逻辑混乱。在平时的编码中,也要避免这种使用lock作为参数的情况。2.3.wait需要包裹两层Object作为Java的基类,提供了四个方法waitwait(timeout)notifynotifyAll,用于处理线程同步问题。可见wait等函数的地位有多高。在平时的工作中,写业务代码的同学是比较少用到这些功能的,所以一旦用到,很容易出问题。但是使用这些函数有一个非常大的前提,就是必须用synchronized包裹起来,否则会抛出IllegalMonitorStateException。比如下面的代码,执行的时候会报错。最终对象条件=newObject();publicvoidfunc(){condition.wait();}类似的方法,以及concurrent包中的Condition对象,使用时也必须出现在lock和unlock函数之间。为什么我们需要在等待之前同步这个对象?因为JVM要求在执行wait的时候,线程需要持有这个对象的monitor。显然,synchronization关键字可以完成这个功能。然而,仅仅这样做是不够的。wait函数通常需要放在while循环中,JDK在代码中已经做了明确的注释。重点:这是因为wait的意思是notify的时候能够向下执行逻辑。但是在notify的时候,这个wait的条件可能已经不成立了,因为在wait期间条件可能发生了变化,需要再做一次判断,所以简单的写在while循环里.最终对象条件=newObject();publicvoidfunc(){synchronized(condition){while(){condition.wait();}}}wait和notifywithifcondition应该包裹两层,一层synchronized,一层while,这才是wait等函数的正确用法。2.4.不要覆盖锁对象使用synchronized关键字时,如果是加在一个普通的方法上,那么被锁的对象就是这个对象;如果它是在静态方法上加载的,那么锁定的对象就是类。synchronized除了用在方法中,还可以直接指定要加锁的对象,加锁代码块,实现细粒度的锁控制。如果锁定的对象被覆盖会怎样?比如下面这一张。Listlisteners=newArrayList();voidadd(Listenerlistener,booleanupsert){synchronized(listeners){Listresults=newArrayList();for(Listenerler:listeners){...}listeners=results;}}上面的代码,因为在逻辑上,强行重新分配了锁listeners对象,会导致锁混淆或者失效。为了安全起见,我们通常将锁对象声明为final。finalListlisteners=newArrayList();或者直接声明一个专用的锁对象,定义为一个普通的Object对象。finalObjectlistenersLock=newObject();2.5.循环中处理异常在异步线程中处理一些定时任务,或者执行时间非常长的批处理,是经常遇到的需求。不止一次看到朋友的程序执行完一部分就停止了。发现这些挂起的根本原因是一行数据有问题,导致整个线程死亡。让我们看一下代码模板。volatilebooleanrun=true;voidloop(){while(run){for(Tasktask:taskList){//do.某某整数a=1/0;}}}在循环函数中,执行我们真正的业务逻辑。执行任务时,发生异常。这时候线程不会继续运行,而是会抛出异常,直接停止。在写普通函数的时候,我们都知道程序的这个行为,但是一旦涉及到多线程,很多同学就会忘记这个环节。值得注意的是,即使是非捕获NullPointerException也会导致线程中止。所以,时刻把要执行的逻辑放在trycatch中是一个很好的习惯。volatilebooleanrun=true;voidloop(){while(run){for(Tasktask:taskList){try{//do.某某整数a=1/0;}catch(Exceptionex){//日志}}}}2.6。HashMap的正确使用HashMap在多线程环境下会产生死循环问题。这个问题得到了广泛的关注,因为它的后果非常严重:CPU占满,代码无法执行,查看时jstack阻塞在get方法上。至于如何提高HashMap的效率,什么时候从红黑树切换到列表,这是阳春白雪千篇一律的世界里的话题。我们下利巴人只关注如何规避问题。网上有详细的文章描述死循环问题的场景,主要是因为HashMap在进行rehash的时候会形成环链。一些获取请求将转到此环。JDK并不认为这是一个bug,虽然它的影响比较恶劣。如果你判断你的集合类会被多线程使用,你可以使用线程安全的ConcurrentHashMap代替。HashMap还有一个安全删除的问题,跟多线程关系不大,但是会抛出ConcurrentModificationException,看起来是多线程的问题。让我们一起来看看吧。Mapmap=newHashMap<>();map.put("xjjdog0","dog1");map.put("xjjdog1","dog2");for(Map.Entryentry:map.entrySet()){Stringkey=entry.getKey();if("xjjdog0".equals(key)){map.remove(key);}}上面的代码会抛出异常,这是由于HashMap的Fail-Fast机制。如果我们想安全地删除一些元素,我们应该使用迭代器。Iterator>iterator=map.entrySet().iterator();while(iterator.hasNext()){Map.Entryentry=iterator.next();}字符串键=entry.getKey();if("xjjdog0".equals(key)){iterator.remove();}}2.7。线程安全的保护范围使用线程安全类,写的代码一定是线程安全的吗?答案是否定的。线程安全类只负责其内部方法是线程安全的。如果我们把它包裹在外面,那么是否能达到线程安全的效果就需要重新讨论了。例如,在以下情况中,我们使用线程安全的ConcurrentHashMap来存储计数。虽然ConcurrentHashMap本身是线程安全的,但是不会再出现死循环的问题。但是addCounter函数显然是不对的,需要用synchronized函数包裹起来。privatefinalConcurrentHashMapcounter;publicintaddCounter(Stringname){Integercurrent=counter.get(name);intnewValue=++current;counter.put(name,newValue);returnnewValue;}这是开发者常踩的坑之一。要实现线程安全,需要看线程安全的范围。如果更大维度的逻辑存在同步问题,那么即使使用线程安全的集合也达不到预期的效果。2.8.volatile的作用是有限的。volatile关键字解决了变量可见性的问题,让你的修改立即被其他线程读取。虽然这个东西在面试的时候被问了很多,包括那些ConcurrentHashMapsquadronvolatile的优化。但是在平时的使用中,你可能真的只接触到boolean变量的值修改。易失性布尔值关闭;publicvoidshutdown(){closed=true;不要将其用于计数或线程同步,例如以下。volatilecount=0;voidadd(){++count;}此代码在多线程环境中不准确。这是因为volatile只保证可见性,不保证原子性,多线程操作不能保证其正确性。直接使用Atomic类或同步关键字有多好,你真的关心纳秒之间的差异吗?2.9.小心处理日期很多时候,日期处理可能会出错。这是因为使用了globalCalendar、SimpleDateFormat等,当多个线程同时执行格式化函数时,会出现数据损坏的情况。SimpleDateFormatformat=newSimpleDateFormat("yyyy-MM-ddhh:mm:ss");DategetDate(Stringstr){returnformat(str);}为了提高,我们通常把SimpleDateFormat放在ThreadLocal中,每个线程一个Copy,这样可以避免一些问题。当然,现在我们可以使用线程安全的DateTimeFormatter。静态DateTimeFormatterFOMATTER=DateTimeFormatter.ofPattern("MM/dd/yyyyHH:mm:ss");publicstaticvoidmain(String[]args){ZonedDateTimezdt=ZonedDateTime.now();System.out.println(FOMATTER.format(zdt));}2.10。不要在构造函数中启动线程在构造函数或静态代码块中启动新线程没有错。但是,强烈不建议这样做。因为Java有继承,如果在构造函数中做这种事情,那么子类的行为就会变得很神奇。另外,这个对象可能在构建完成之前就被交付到另一个地方使用,从而导致一些不可预知的行为。所以把线程的启动放在一个普通的方法中是更好的选择,比如start。它可以减少错误发生的机会。来源:小姐姐的品味作者:小姐姐的狗