Java并发编程有了它不用怕

Java多线程概述

在Java中使用多线程是提高程序并发响应能力的重要手段,但同时它也是一把双刃剑;如果使用不当也很容易导致程序出错,并且还很难直观地找到问题。这是因为:1)、线程运行本身是由操作系统调度,具有一定的随机性;2)、Java共享内存模型在多线程环境下很容易产生线程安全问题;3)、不合理的封装依赖,极容易导致发布对象的不经意逸出。

所以,要用好多线程这把剑,就需要对Java内存模型、线程安全问题有较深的认识。但由于Java丰富的生态,在实际研发工作中,需要我们自己进行并发处理的场景大都被各类框架或组件给屏蔽了。这也是造成很多Java开发人员对并发编程意识淡薄的主要原因。

首先从Java内存模型的角度理解下使用多线程编程最核心的问题,具体如下图所示:

如上图所示,在Java内存模型中,对于用户程序来说用得最频繁的就是堆内存和栈内存,其中堆内存主要存放对象及数组,例如由new()产生的实例。而栈内存则主要是存储运行方法时所需的局部变量、操作数及方法出口等信息。

其中堆内存是线程共享的,一个类被实例化后生成的对象、及对象中定义的成员变量可以被多个线程共享访问,这种共享主要体现在多个线程同时执行、同一个对象实例的某个方法时,会将该方法中操作的对象成员变量分别以多个副本的方式拷贝到方法栈中进行操作,而不是直接修改堆内存中对象的成员变量值;线程操作完成后,会再次将修改后的变量值同步至堆内存中的主内存地址,并实现对其他线程的可见。

这个过程虽然看似行云流水,但在JVM中却至少需要6个原子步骤才能完成,具体如下图所示:

如上图所示,在不考虑对共享变量进行加锁的情况下,堆内存中一个对象的成员变量被线程修改大概需要以下6个步骤:

1、read(读取):从堆内存中的读取要操作的变量;

2、load(载入):将读取的变量拷贝到线程栈内存;

3、use(使用):将栈内存中的变量值传递给执行引擎;

4、assign(赋值):将从执行引擎得到的结果赋值给栈内存中变量;

5、store(存储):将变更后的栈内存中的变量值传递到主内存;

6、write(写入):变更主内存中的变量值,此时新值对所有线程可见;

由此可见,每个线程都可以按这几个步骤并行操作同一个共享变量。可想而知,如果没有任何同步措施,那么在多线程环境下,该共享变量的值将变得飘忽不定,很难得到最终正确的结果。而这就是所谓的线程安全问题,也是我们在使用多线程编程时,最需要关注的问题!

线程池的使用

在实际场景中,多线程的使用并不是单打独斗,线程作为宝贵的系统资源,其创建和销毁都需要耗费一定的系统资源;而无限制的创建线程资源,也会导致系统资源的耗尽。所以,为了重复使用线程资源、限制线程的创建行为,一般都会通过线程池来实现。以Java Web服务中使用最广的Tomcat服务器举例,为了并行处理网络请求就使用了线程池,源码示例如下:


  1. public boolean processSocket(SocketWrapperBase<S> socketWrapper, 
  2.         SocketEvent event, boolean dispatch) { 
  3.     try { 
  4.         if (socketWrapper == null) { 
  5.             return false
  6.         } 
  7.         SocketProcessorBase<S> sc = null
  8.         if (processorCache != null) { 
  9.             sc = processorCache.pop(); 
  10.         } 
  11.         if (sc == null) { 
  12.             sc = createSocketProcessor(socketWrapper, event); 
  13.         } else { 
  14.             sc.reset(socketWrapper, event); 
  15.         } 
  16.         //这里通过线程池对线程执行进行管理 
  17.         Executor executor = getExecutor(); 
  18.         if (dispatch && executor != null) { 
  19.             executor.execute(sc); 
  20.         } else { 
  21.             sc.run(); 
  22.         } 
  23.     } catch (RejectedExecutionException ree) { 
  24.         getLog().warn(sm.getString("endpoint.executor.fail", socketWrapper) , ree); 
  25.         return false
  26.     } catch (Throwable t) { 
  27.         ExceptionUtils.handleThrowable(t); 
  28.         // This means we got an OOM or similar creating a thread, or that 
  29.         // the pool and its queue are full 
  30.         getLog().error(sm.getString("endpoint.process.fail"), t); 
  31.         return false
  32.     } 
  33.     return true

上述代码为Tomcat源码使用线程池并发处理网络请求的示例,这里以Tomcat为例,主要是因为基于Spring Boot、Spring MVC开发的Web服务大都运行在Tomcat容器,而对于线程、线程池使用的复杂度都被屏蔽在中间件和框架中了,所以很多同学虽然写了不少Java代码,但在业务研发中额外使用线程的场景可能并不多,举这个例子的目的就是为了提升下并发编程的意识!

在Java中使用线程池的主要方式是Executor框架,该框架作为JUC并发包的一部分,为Java程序提供了一个灵活的线程池实现。其逻辑层次如下图所示:

如图所示,使用Executor框架,既可以通过直接自定义配置、扩展ThreadPoolExecutor来创建一个线程池,也可以通过Executors类直接调用“newSingleThreadExecutor()、newFixedThreadPool()、newCachedThreadPool()”这三个方法来创建具有一定功能特征的线程池。

除此之外,也可以通过自定义配置、扩展ScheduledThreadPoolExecutor来创建一个具有周期性、定时功能的线程池,例如线程10s后运行、线程每分钟运行一次等。同样,与ThreadPoolExecutor一样,如果不想自定义配置,也可以通过Executors类直接调用“newScheduledThreadPool()、newSingleThreadScheduledExecutor()”这两个方法来分别创建具备自动线程规模扩展能力和线程池中只允许有单个线程的特定线程池。

而ForkJoinPool是jdk1.8以后新增的一种线程池实现类型,类似于Fork-Join框架所支持的功能。这是一种可以将一个大任务拆分成多个任务队列,并具体分配给不同线程处理的机制,而关键的特性在于,通过窃取算法,某个线程在执行完本队列任务后,可以窃取其他队列的任务进行执行,从而最大限度提高线程的利用效率。

在实际应用中,虽然可以通过Executors方便的创建单个线程、固定线程或具备自动收缩能力的线程池,但一般还是建议直接通过ThreadPoolExecutor或ScheduledThreadPoolExecutor自定义配置,这主要是因为Executors默认创建的线程池,很多采用的是无界队列,例如LinkedBlockingQueue,这样线程就可以被无限制的添加都线程池的任务执行队列,如果请求量过大容易造成OOM。

【声明】:芜湖站长网内容转载自互联网,其相关言论仅代表作者个人观点绝非权威,不代表本站立场。如您发现内容存在版权问题,请提交相关链接至邮箱:bqsm@foxmail.com,我们将及时予以处理。

相关文章