xiaobenchi

导航

并发编程实践

并发编程实践

1. ArrayBlockingQueue的使用

这一节我们讲解logback异步打印机制中ArrayBlockingQueue的使用

  • 异步日志打印模型概述

    在高并发、高流量且响应时间要求比较小的系统中同步打印日志已经满足不了需求了,这是因为打印日志本身是需要写磁盘的,写磁盘的操作会暂时则是调用打印日志的业务线程,这回造成调用线程的rt增加。

    同步日志打印模型

    异步日志打印模型

    其实logback的异步日志模型是一个多生产者-单消费者模型,其通过使用队列把同步日志打印转换为了异步,业务线程只需要通过调用异步appender把日志任务放入日志队列,而日志线程则负责使用同步的appender进行具体的日志打印。日志打印线程只需要负责生产日志并将其放入队列,而不需要关心消费线程何时把日志具体写入磁盘。

  • 异步日志与具体实现

    一般配置同步日志打印时会在logback的xml文件中配置如下内容。

    然后以如下方式使用。

    /**
    * Hello world!
    */
    public class App{
        //根据日志logger名称获取具体日志打印logger
        private static Logger logger = LoggerFactory.getLogger("PROJECT_LOGGER");
        
        public static void main(String[] args){
            logger.warn("Hello World!");
            logger.warn("a{},b{}","hello","worked");
        }
    }
    

    要把同步日志打印改为异步则需要修改logback的xml配置文件如下

    由以上代码可以看出,AsyncAppender是实现异步日志的关键。

  • 异步日志实现原理

    • AsyncAppender的类图结构

    AsyncAppender继承自AsyncAppenderBase,其中后者具体实现了异步日志模型的主要功能,前者只是重写了其中的一些方法。由该图可知,logback中的异步日志队列是一个阻塞队列,其实就是有界阻塞队列ArrayBlockingQueue,其中queueSize表示有界队列的元素个数,默认为256个。

    worker是个线程,也就是异步日志打印模型中的单消费者线程。aai是一个appender的装饰器,里面存放同步日志的appender,其中appenderCount记录aai里面附加的同步appender的个数。neverBlock用来指示当日志队列满时是否阻塞打印日志的线程。discardingThreshold是一个阈值,当日志队列里面的空闲元素个数小于该值时,新来的某些级别的日志会被直接丢弃。

    • 创建日志队列以及启动消费线程

      public void start(){
          //...
          //(1)日志队列为有界阻塞队列
          blockingQueue = new ArrayBlockingQueue<E>(queueSize);
          //(2)如果没设置discardingThreshold则设置为队列大小的1/5
          if(discardingThreshold == UNDEFINED)
              discardingThreshold = queueSize/5;
          //(3)设置消费线程为守护线程,并设置日志标志
          worker.setDaemon(true);
          worker.setName("AsyncAppender-worker-"+worker.getName());
          //(4)设置启动消费线程
          super.start();
          worker.start();
      }
      

      由以上代码可知,logback使用的是有界队列ArrayBlockingQueue,之所以使用有界队列是考虑内存溢出问题。在高并发下写日志的QPS会很高,如果设置为无界队列,队列本身会占用很大的内存,很可能会造成OOM。

      这里消费日志队列的worker线程被设置为守护线程,这意味着当主线程运行结束并且当前没有用户线程时,该worker线程会随着JVM的退出而终止,而不管日志队列里面是否还有日志任务未被处理。

    • 具体进行日志打印的AsyncAppender的append方法。

      protected void append(E eventObject){
          //(5)调用AsyncAppend重写的isDiscardable方法
          if(isQueueBelowDiscaredingThreshold() && isDiscardable(eventObject)){
              return;
          }
          
          //...
          //(6)将日志放入队列
          put(eventObject);
      }
      
      private boolean isQueueBelowDiscardingThreshold(){
          return (blockingQueue.remainingCapacity() < discardingThreshold);
      }
      

      代码(5)调用了AsyncAppender重写的isDiscardable方法

      //(7)
      protected boolean isDiscardable(ILoggingEvent event){
          Level level = event.getLevel();
          return level.toInt() <= Level.INFO_INT;
      }
      

      结合代码(5)和(7)可知,如果当前日志级别小于等于INFO_INT并且当前队列剩余容量小于discardingThreshold则会直接丢弃这些日志任务。

      代码(6)中的put方法

      private void put(E eventObject){
          //(8)
          if(neverBlock){
              blockingQueue.offer(eventObject);
          }else{
              try{
                  //(9)
                  blockingQueue.put(eventObject);
              }catch(InterruptedException e){
                  Thread.currentThread().interrupt();
              }
          }
      }
      

      如果neverBlock被设置为false(默认为false)则会调用阻塞队列的put方法,而put是阻塞的,也就是说如果当前队列满,则在调用put方法向队列放入一个元素时调用线程会被阻塞直到队列有空余空间。

      put方法的实现

      public void put(E e) throws InterruptedException{
          //...
          
          final ReentrantLock lock = this.lock;
          lock.lockInterruptibly();
          try{
              //如果队列满,则调用await方法阻塞当前调用线程
              while(count == items.length)
                  notFull.await();
              enqueue(e);
          }finally{
              lock.unlock();
          }
      }
      

      offer方法的实现

      public boolean offer(E e){
          //...
          final ReentrantLock lock = this.lock;
          lock.lock();
          try{
              //如果队列满则直接返回false
              if(count == items.length)
                  return false;
              else{
                  enqueue(e);
                  reurn true;
              }
          }finally{
              lock.unlock();
          }
      }
      

      最后看addAppender方法

      public void addAppender(Appender<E> newAppender){
          if(appenderCount == 0){
              appenderCount++;
              //...
              aai.Appender(newAppender);
          }else{
              addWarn("One and only one appender may be attached to AsyncAppender.");
              addWarn("Ignoring additional appender named[" + newAppender.getName()+"]");
          }
      }
      
    • 到这里我们已经分析完了日志生产线程把日志任务放入日志队列的实现,下面一起来看消费线程是如何从队列里面消费日志任务并将其写入磁盘的。

      由于消费线程是一个线程,所以就从worker的run方法开始。

      class Worker extends Thread{
          public void run(){
              
              AysncAppenderBase<E> parent = AsyncAppenderBase.this;
              AppenderAttachableImpl<E> aai = parent.aii;
              
              //(10)一直循环直到该线程被中断
              while(parent.isStarted()){
                  try{
                      //(11)从阻塞队列获取元素
                      E e = parent.blockingQueue.take();
                      aai.appendLoopOnAppenders(e);
                  }catch(InterruptedException ie){
                      break;
                  }
              }
              //(12)到这里说明该线程被中断,则把队列里面的剩余任务刷新到磁盘
              for(E e:parent.blockingQueue){
                  aai.appendLoopOnAppenders(e);
                  parent.blockingQueue.remove(e);
              }
              ...
          }
      }
      
  • 小结

    本节结合logback中异步日志的实现介绍了并发组件ArrayBlockingQueue的使用,包括put、offer方法的使用场景以及它们之间的区别,take方法的使用,同时也介绍了如何使用ArrayBlockingQueue来实现一个多生产者-单消费者模型。另外使用ArrayBlockingQueue时需要注意合理设置队列的大小以免造成OOM,队列满或者剩余元素比较少时,要根据具体场景制定一些抛弃策略以避免队列满时业务线程被阻塞。

2. Tomcat的NioEndPoint中ConcurrentLinkedQueue的使用

本节讲解apache-tomcat-7.0.32-src源码中ConcurrentLinkedQueue的使用。

  • Tomcat容器结构以及NioEndPoint的作用

    • Tomcat的容器结构

    其中,Connector是一个桥梁,将server和Engine连接。Connector的作用是接受客户端的请求,然后把请求委托给Engin容器处理。在Connector的内部具体使用Endpoint进行处理,根据处理方式的不同Endpoint可分为NioEndpoint、JIoEndpoint、AprEndpoint。本节介绍NioEndpoint中的并发组件队列的使用。

    • NioEndpoint

      NioEndpoint三大组件关系图

      Acceptor是套接字接受线程,用来接收用户的请求,并把请求封装为时间任务放入Poller的队列,一个Connector里面只有一个Acceptor。

      Poller是套接字处理线程,每个Poller内部都有一个独有的队列,Poller线程则从自己的队列里面获取具体事件任务,然后将其交给Worker处理。Poller线程的个数与处理器的核数有关。

  • 生产者——Acceptor线程

    Acceptor线程的作用是接受客户端发来的请求并将其放入Poller的事件队列。

    • Accepter处理请求的简明时序图

    • Acceptor的源码,看其如何把接受的套接字连接放入队列

      protected class Acceptor extends AbstractEndpoint.Acceptor{
          @override
          public void run(){
              
              int errorDelay = 0;
              
              //(1) 一直循环直到接收到shutdown命令
              while(running){
                  //...
                  if(!running){
                      break;
                  }
                  state = AcceptorState.EUNNING;
                  
                  try{
                      //(2)如果达到max connections个请求则等待
                      countUpOrAwaitConnection();
                      
                      SocketChannel socket = null;
                      
                      try{
                          //(3)从TCP缓存获取一个完成三次握手的套接字,没有则阻塞
                          socket = serverSocket.accept();
                      }catch(IOException ioe){
                          //...
                      }
                      errorDelay = 0;
                      if(running && !paused){
                          //(4)设置套接字参数并封装套接字为事件任务,然后放入Poller的队列
                          if(!setSocketOptions(socket)){
                              countDownConnection();
                              closeSocket(socket);
                          }
                      }else{
                          countDownConnection();
                          closeSocket(socket);
                      }
                      //...
                  }catch(SocketTimeoutException sx){
                      //...
                  }
                  state = AcceptorState.ENDED;
              }
          }
      }
      

      当代码(3)获取到一个连接套接字后,代码(4)会调用setSocketOptions设置该套接字。

      protected boolean setSocketOptions(SocketChannel socket){
          //处理链接
          try{
              //...
              //封装链接套接字为channel并注册到Poller队列
              getPoller0().register(channel);
          }catch(Throwable t){
              //...
              return false;
          }
          return true;
      }
      

      代码(5)将连接套接字封装为一个channel对象,并将其注册到poller对象的队列

      //具体注册到事件队列
      public void register(final NioChannel socket){
          //...
          PollerEvent r = eventCache.poll();
          ka.interestOps(SelectionKey.OP_READ);
          if(r == null) r = new PollerEvent(socket,ka,OP_REGISTER);
          else r.reset(socket,ka,OP_REGISTER);
          
          addEvent(r);
      }
      
      public void addEvent(Runnable event){
          events.offer(event);
          //...
      }
      

      其中,events的定义如下

      protected ConcurrentLinkedQueue<Runnable> events = new ConcurrentLinkedQueue<Runnable>();
      

      由此可见,events是一个无界队列ConcurrentLinkedQueue。

  • 消费者——Poller线程

    Poller线程的作用是从事件队列中获取事件并进行处理。

    • 时序图

    • Poller线程的run方法代码逻辑

      public void run(){
          while(true){
              try{
                  //...
                  if(close){
                      //...
                  }else{
                      //(6)从事件队列获取事件
                      hasEvents = events();
                  }
                  try{
                      //...
                  }catch(NullPointerException x){
                      //...
                  }
                  Iterator<SelectionKey> iterator = keyCount > 0 selector.selectedKeys().iterator() : null;
                  //(7)遍历所有注册的channel并对感兴趣的事件进行处理
                  while(iterator != null && iterator.hasNext()){
                      SelectionKey sk = iteraor.next();
                      KeyAttachment attachment = (KeyAttachment)sk.attachment();
                      
                      if(attachment == null){
                          iterator.remove();
                      }else{
                          attachment.access();
                          iterator.remove();
                          
                          //(8)具体调用SocketProcessor进行处理
                          processKey(sk,attachment);
                      }
                  }//while
                  //...
                  
              }catch(OutOfMemoryError oom){
                  //...
              }
          }//while
          //...
      }
      

      其中,代码(6)从poller的事件队列获取一个事件,events()的代码如下

      public boolean events(){
          boolean result = false;
          
          //从队列中获取任务并执行
          Runnable r = null;
          while((r = events.poll()) != null){
              result = true;
              try{
                  r.run();
                  //...
              }catch(Throwable x){
                  //...
              }
          }
          return result;
      }
      
  • 小结

    本节通过分析Tomcat中NioEndPoint的实现源码介绍了并发组件ConcurrentLinkedQueue的使用。NioEndPoint的思想也是使用队列将同步转为异步,并且由于ConcurrentLinkedQueue是无界队列,所以需要让用户提供一个设置队列大小的接口以防止队列元素过多导致OOM。

3. 并发组件ConcurrentHashMap使用注意事项

ConcurrentHashMap虽然为并发安全的组件,但是使用不当仍然会导致程序错误。本节通过简单的案例来复现这些问题,并给出开发时如何避免的策略。

这里借用直播的一个场景,在直播业务中,每个直播间对应一个topic,每个用户进入直播间时会把自己设备的ID绑定到这个topic上,也就是一个topic对应一堆用户设备。可以使用map来维护这些信息,其中key为topic,value为设备的list。下面使用代码来模拟多用户同时进入直播间时map信息的维护

public class TestMap{
    //(1)创建map,key为topic,value为设备列表
    static ConcurrentHashMap<String,List<String>> map = new ConcurrentHashMap<>();
    public static void main(String[] args){
        //(2)进入直播间topic1,线程one
        Thread threadOne = new Thread(new Runnable(){
            public void run(){
                List<String> list1 = new ArrayList<>();
                list1.add("device1");
                list1.add("device2");
                
                map.put("topic1",list1);
                System.out.println(JSON.toJSONString(map));
            }
        });
        
        //(3)进入直播间topic1,线程two
        Thread threadTwo = new Thread(new Runnable(){
            public void run(){
                List<String> list1 = new ArrayList<>();
                list1.add("device11");
                list1.add("device22");
                
                map.put("topic1",list1);
                System.out.println(JSON.toJSONString(map));
            }
        });
        
        //(4)进入直播间topic2,线程three
        Thread threadThree = new Thread(new Runnable(){
            public void run(){
                List<String> list1 = new ArrayList<>();
                list1.add("device111");
                list1.add("device222");
                
                map.put("topic2",list1);
                System.out.println(JSON.toJSONString(map));
            }
        });
        
        //(5)启动线程
        ThreadOne.start();
        ThreadTwo.start();
        threadThree.start();
    }
}

topic1房间中的用户会丢失一部分,这是因为put方法如果发现map里面存在这个key,则使用value覆盖该key对应的老的value值。而putIfAbsent方法则是,如果发现已经存在该key则返回该key对应的value,但并不进行覆盖,如果不存在则新增该key,并且判断和写入是原子性操作。使用putIfAbsent替代put方法。

4. SimpleDateFormat是线程不安全的

SimpleDateFormat是Java提供的一个格式化和解析日期的工具类,在日常开发中经常会用到,但是由于它是线程不安全的,所以多线程共用一个SimpleDateFormat实例对日期进行解析或者格式化会导致程序出错。本节来揭示它为何是线程不安全的,以及如何避免该问题。

  • 问题复现

    public class TestSimpleDateFormat{
        //(1)创建单例实例
        static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        
        public static void main(String[] args){
            //(2)创建多个线程,并启动
            for(int i = 0; i < 10; ++i){
                Thread thread = new Thread(new Runnable(){
                    public void run(){
                        try{
                            //(3)使用单例日期实例解析文本
                            System.out.println(sdf.parse("2017-12-13 15:17:27"));
                        }catch(ParseException e){
                            e.printStackTrace();
                        }
                    }
                });
                
                thread.start(); //(4)启动线程
            }
        }
    }
    
  • 问题分析

    • SimpleDateFormat类图结构

      可以看到,每个SimpleDateFormat实例里面都有一个Calendar对象,后面我们就会知道,SimpleDateFormat之所以是线程不安全的,就是因为Calendar是线程不安全的。后者之所以是线程不安全的,是因为其中存放日期数据的变量都是线程不安全的,比如fields、time等。

    • parse

      从代码层面来看下parse方法做了什么事情

      public Date parse(String text, ParsePosition pos){
          //(1)解析日期字符串,并将解析好的数据放入CalendarBuilder的实例calb中
          
          Date parsedDate;
          try{
              //(2)使用calb中解析好的日期数据设置calendar
              parsedDate = calb.establish(calendar).getTime();
              //...
          }
          
          catch (IllegalArgumentException e){
              //...
              return null;
          }
          
          return parsedDate;
      }
      

      代码(1)的主要作用是解析日期字符串并把解析好的数据放入 CalendarBuilder的实例calb中。CalendarBuilder是一个建造者模式,用来存放后面需要的数据。

      代码(2)使用calb中解析好的日期数据设置calendar。

      calb.establish的代码如下

      Calendar establish(Calendar cal){
          //...
          //(3)重置日期对象cal的属性值
          cal.clear();
          //(4)使用calb中的属性设置cal
          //...
          //(5)返回设置好的cal对象
           return cal;
      }
      

      代码3重置Calendar对象里面的属性值。

      代码4使用calb中解析好的日期数据设置cal对象。

      代码5返回设置好的cal对象。

      从以上代码可以看出,代码(3)、代码(4)和代码(5)并不是原子性操作。当多个线程调用parse方法时,程序可能会出现错误。

  • 解决方法

    • 每次使用都new一个SimpleDateFormat实例,生成和回收开销大。
    • 让代码(3),(4),(5)成为原子性操作,高并发下效率低。
    • 使用ThreadLocal。
  • 小结

    本节通过简单介绍SimpleDateFormat的原理解释了为何SimpleDateFormat是线程不安全的,应该避免在多线程下使用SimpleDateFormat的单个实例。

5. 使用Timer时需要注意的事情

当一个Timer运行多个TimerTask时,只要其中一个TimerTask在执行中向run方法外抛出了异常,则其他任务也会自动终止。

  • 问题产生

    复现问题

    public class TestTimer{
        //创建定时器对象
        static Timer timer = new Timer();
        
        public static void main(String[] args){
            //延迟任务1,延迟500ms进行
            timer.schedule(new TimerTask(){
                
                @override
                public void run(){
                    System.out.println("---one Task---");
                    try{
                        Thread.sleep(1000);
                    }catch(InterruptedException e){
                        e.printStackTrace();
                    }
                    throw new RuntimeException("error");
                }
            },500);
            
            //延迟任务2,延迟1000ms进行
            timer.schedule(new TimerTask(){
                
                @override
                public void run(){
                    System.out.println("---two Task---");
                    try{
                        Thread.sleep(1000);
                    }catch(InterruptedException e){
                        e.printStackTrace();
                    }
                    throw new RuntimeException("error");
                }
            },1000);
        }
    }
    

    输出结果有误

  • Timer实现原理分析

    • TaskQueue是一个由平衡二叉树堆实现的优先级队列,每个Timer对象内部有一个TaskQueue队列。用户线程调用Timer的schedule方法就是把TimerTask任务添加到TaskQueue队列。在调用schedule方法时,long delay参数用来指明该任务延迟多少时间执行。

    • TimerThread时具体执行任务的线程,他从TaskQueue队列里面获取优先级最高的任务进行执行。需要注意的是,只有执行完了当前任务才会从队列里获取下一个任务,而不管队列里是否有任务到了设置的delay时间。一个Timer只有一个TimerThread线程,所以可知Timer的内部实现是一个多生产者-单消费者模型。

    • TimerThread的run方法逻辑

      从实现模型可知,限制TimerTask性能的关键点就在于单消费者的TimerThread。

      public void run(){
          try{
              mainLoop();
          }finally{
              synchronized(queue){
                  newTasksMayBeSchedule = false;
                  queue.clear();
              }
          }
      }
      
      private void mainLoop(){
          while(true){
              try{
                  TimerTask task;
                  boolean taskFired;
                  synchronized(queue){
                      //...
                  }
                  if(taskFired)
                      task.run();
              }catch(InterruptedException e){
                  
              }
          }
      }
      

      当任务在执行过程中抛出InterruptedException之外的异常时,唯一的消费线程就会因为抛出异常而终止,那么队列里的其他待执行的任务就会被清除。所以在TimerTask的run方法内最好使用try-catch结构捕捉可能的异常,不要把异常抛到run方法之外。

      要实现Timer功能,使用ScheduledThreadPoolExecutor的schedule是比较好的选择。如果ScheduledThreadPoolExecutor中的一个任务抛出异常,其他任务则不受影响。

  • 小结

    ScheduledThreadPoolExecutor是并发包提供的组件,其提供的功能包含但不限于Timer。Timer是固定的多线程生产单线程消费,但是ScheduledThreadPoolExecutor是可以配置的,既可以是多线程生产单线程消费也可以是多线程生产多线程消费,所以在日常开发中使用定时器功能时应该优先使用ScheduledThreadPoolExecutor。

6. 对需要复用但是会被下游修改的参数要进行深复制

  • 问题的产生

    首先介绍消息发送的场景,比如每个安装有手淘App的移动设备有一个设备ID,每个App(比如手淘App)有一个appkey用来标识这个应用。可以根据不同的appkey选择不同的发送策略,对注册到自己的设备进行消息发送,每个消息有一个消息ID和消息体字段。

    实例代码

    //(1)不同appkey注册不同的服务
    static Map<Integer,StrategyService> serviceMap = new HashMap<Integer,StrategyService>();
    static{
        serviceMap.put(111,new StrategyOneService());
        serviceMap.put(222,new StrategyTwoService());
    }
    
    public static void main(String[] args){
        //(2)key为appkey,value为设备ID列表
        Map<Integer,List<String>> appKeyMap = new HashMap<Intager,List<String>>();
        
        //(3)创建appkey=111的设备列表
        List<String> oneList = new ArrayList<>();
        oneList.add("device_id1");
        appKeyMap.put(111,oneList);
        
        //创建appkey=222的设备列表
        List<String> twoList = new ArrayList<>();
        oneList.add("device_id2");
        appKeyMap.put(222,twoList);
        
        //(4)创建消息
        List<Msg> msgList = new ArrayList<>();
        Msg msg = new Msg();
        msg.setDataId("abc");
        msg.setBody("hello");
        msgList.add(msg);
        
        //(5)根据不同的appKey使用不同的策略进行处理
        appKeyItr = appKeyMap.keySet().iterator();
        while(appKeyItr.hasNext()){
            int appKey = appKeyItr.next();
            //这里根据appKey获取自己的消息列表
            StrategyService strategyService = serviceMap.get(appKey);
            if(null != strategyService){
                strategyService.sendMsg(msgList,appKeyMap.get(appKey));
            }else{
                System.out.println(String.format("appkey:%s,is not registered service",appKey));
            }
        }
    }
    

    代码(1)给不同的appkey注册对应的处理策略,appkey=111和appkey=222时分别注册了StrategyOneService和StrategyTwoService服务,它们都实现了StrategyService接口。

    每个消息对应一个DataId,其用来唯一标识一个消息。在每个发送消息的实现里面都会添加一个前缀以用于分类统计。

    代码(2)和代码(3)则是给对应的appkey新增设备列表。
    代码(4)创建消息体。
    代码(5)实现根据不同的appkey使用不同的发送策略进行消息发送。

    问题产生了。这个例子运行的结果是固定的,但是如果在每个发送消息的sendMsg方法里面异步修改消息的DataId,那么运行的结果就不是固定的了。

  • 问题分析

    分析输出结果可以知道,代码(5)先执行了appkey=222的发送消息服务,然后再执行appkey=111的服务,之所以后者打印出来的DataId是oneService_TwoService而不是oneService,是因为在appkey=222的消息服务里面修改了消息体msg的DataId为TwoService_abc,而方法sendMsg里面的消息是引用传递的,所以导致appkey=111的服务在调用sendMsg方法时msg里面的DataId已经变成了TwoService_abc,然后在sendMsg方法内部又会在它的前面添加oneService前缀,最后DataId就变成了oneService_TwoService_abc。

  • 解决方法

如上代码使用工具类BeanUtils.cloneBean而不是new ArrayList<>(msgList)来构造每个appkey对应的消息列表。

  • 小结

    本节通过一个简单的消息发送例子说明了需要复用但是会被下游修改的参数要进行深复制,否则会导致出现错误的结果;另外引用类型作为集合元素时,如果使用这个集合作为另外一个集合的构造函数参数,会导致两个集合里面的同一个位置的元素指向的是同一个引用,这会导致对引用的修改在两个集合中都可见,所以这时候需要对引用元素进行深复制。

7. 创建线程和线程池时要指定与业务相关的名称

在日常开发中,当在一个应用中需要创建多个线程或者线程池时最好给每个线程或者线程池根据业务类型设置具体的名称,以便在出现问题时方便进行定位。

  • 创建线程需要有线程名

    当一个系统中有多个业务模块而每个模块又都使用自己的线程时,除非抛出与业务相关的异常,否则你根本没法判断是哪一个模块出现了问题。

    static final String THREAD_SAVE_ORDER = "THREAD_SAVE_ORDER";
    static final String THREAD_SAVE_ADDR = "THREAD_SAVE_ADDR";
    
    public static void main(String[] args){
        //订单模块
        Thread threadOne = new Thread(new Runnable() {
            public void run(){
                System.out.println("保存订单的线程");
                throw new NullPointerException();
            }
        },THREAD_SAVE_ORDER);
        
        //发货模块
        Thread threadTwo = new Thread(new Runnable() {
            public void run(){
                System.out.println("保存收货地址的线程");
                throw new NullPointerException();
            }
        },THREAD_SAVE_ADDR);
        
        threadOne.start();
        threadTwo.start();
    }
    

    从运行结果就可以定位到是保存订单模块抛出了NPE异常,一下子就可以找到问题所在。

  • 创建线程池时也需要指定线程池的名称

    创建线程池如下

8. 使用线程池的情况下,当程序结束的记得调用shutdown关闭线程池

在日常开发中为了便于线程的有效复用,经常会用到线程池,然而使用完线程池后如果不调用shutdown关闭线程池,则会导致线程池资源一直不被释放。

JVM退出的条件是当前不存在用户线程,而线程池默认的ThreadFactory创建的线程是用户线程,,如果没有任务则会被阻塞,所以线程池里面的用户线程一直存在。而shutdown方法的作用就是让这些核心线程终止。

9. 线程池使用FutureTask时需要注意的事情

线程池使用FutureTask时如果把拒绝策略设置为DiscardPolicy和DiscardOldestPolicy,并且在被拒绝的任务的Future对象上调用了无参get方法,那么调用线程会一直被阻塞。

  • 问题分析

    看看线程池的submit方法都做了什么。

    public Future<?> submit(Runnable task){
        //...
        //(1)装饰Runnable为Future对象
        RunnableFuture<Void> ftask = newTaskFor(task,null);
        execute(ftask);
        //(6)返回Future对象
        return ftask;
    }
    
    protected <T> RunnableFuture<T> newTaskFor(Runnable runnable,T value){
        return new FutureTask<T>(runnable,value);
    }
    
    public void execute(Runnable command){
        //...
        //(2)如果线程个数小于核心线程数则新增处理线程
        int c = ctl.get();
        if(workerCountOf(c) < corePoolSize){
            if(addWorker(command,true))
                return;
            c = ct1.get();
        }
        
        //(3)如果当前线程个数已经达到核心线程数则把任务放入队列
        if(isRunning(c) && workQueue.offer(command)){
            int recheck = ct1.get();
            if(!isRunning(recheck) && remove(command))
                reject(command);
            else if(workerCountOf(recheck) == 0)
                addWorker(null,false);
        }
        
        //(4)尝试新增处理线程
        else if(!addWorker(command,false))
            reject(command); //(5)新增失败则调用拒绝策略
    }
    

    要找到上面例子中问题所在,只需要看代码(5)对被拒绝任务的影响,这里先看下拒绝策略DiscardPolicy的代码。

拒绝策略的rejectedExecution方法什么都没做,代码(4)调用submit后会返回一个Future对象。这里有必要再次重申,Future是有状态的。

在代码(1)中使用newTaskFor方法将Runnable任务转换为FutureTask,而在FutureTask的构造函数里面设置的状态就是NEW。

当Future的状态>COMPLETING时调用get方法才会返回,而明显DiscardPolicy策略在拒绝元素时并没有设置该Future的状态,后面也没有其他机会可以设置该Future的状态,所以Future的状态一直是NEW,所以一直不会返回。同理,DiscardOldestPolicy策略也存在这样的问题,最老的任务被淘汰时没有设置被淘汰任务对应Future的状态。
所以当使用Future时,尽量使用带超时时间的get方法,这样即使使用了DiscardPolicy拒绝策略也不至于一直等待,超时时间到了就会自动返回。如果非要使用不带参数的get方法则可以重写DiscardPolicy的拒绝策略,在执行策略时设置该Future的状态大于COMPLETING即可。但是我们查看FutureTask提供的方法,会发现只有cancel方法是public的,并且可以设置FutureTask的状态大于COMPLETING,则重写拒绝策略的具体代码如下。

  • 小结

    本节通过案例介绍了在线程池中使用FutureTask时,当拒绝策略为DiscardPolicy和DiscardOldestPolicy时,在被拒绝的任务的FutureTask对象上调用get()方法会导致调用线程一直阻塞,所以在日常开发中尽量使用带超时参数的get方法以避免线程一直阻塞。

10. 使用TheadLock不当可能会导致内存泄漏

本节着重介绍使用ThreadLocal会导致内存泄漏的原因,并给出使用ThreadLocal导致内存泄漏的案例。

  • 为何会出现内存泄漏

    在基础篇我们讲了,ThreadLocal只是一个工具类,具体存放变量的是线程的threadLocals变量。threadLocals是一个ThreadLocalMap类型的变量。

    该类型如下图所示。

    当一个线程调用ThreadLocal的set方法设置变量时,当前线程的ThreadLocalMap里就会存放一个记录,这个记录的key为ThreadLocal的弱引用,value则为设置的值。如果当前线程一直存在且没有调用ThreadLocal的remove方法,并且这时候在其他地方还有对ThreadLocal的引用,则当前线程的ThreadLocalMap变量里面会存在对ThreadLocal变量的引用和对value对象的引用,它们是不会被释放的,这就会造成内存泄漏。

    考虑这个ThreadLocal变量没有其他强依赖,而当前线程还存在的情况,由于线程的ThreadLocalMap里面的key是弱依赖,所以当前线程的ThreadLocalMap里面的ThreadLocal变量的弱引用会在gc的时候被回收,但是对应的value还是会造成内存泄漏,因为这时候ThreadLocalMap里面就会存在key为null但是value不为null的entry项。

    ThreadLocalMap的remove方法中的清理过程。


  • 在线程池中使用ThreadLocal导致的内存泄漏


代码(1)创建了一个核心线程数和最大线程数都为5的线程池。
代码(2)创建了一个ThreadLocal的变量,泛型参数为LocalVariable,LocalVariable内部是一个Long数组。
代码(3)向线程池里面放入50个任务。
代码(4)设置当前线程的localVariable变量,也就是把new的LocalVariable变量放入当前线程的threadLocals变量中。
由于没有调用线程池的shutdown或者shutdownNow方法,所以线程池里面的用户线程不会退出,进而JVM进程也不会退出。

然后去掉localVariable.remove()注释,再运行,观察堆内存变化,如下图所示。

  • 在tomcat的Servlet中使用ThreadLocal导致内存泄漏

    首先看一个Servlet的代码


    代码(1)创建一个localVariable对象。
    代码(2)在Servlet的doGet方法内设置localVariable值。
    代码(3)打印当前Servlet的实例。
    代码(4)打印当前线程。

    如果在访问该Servlet的同时打开jconsole观察堆内存,会发现内存飙升,究其原因是因为工作线程在调用Servlet的doGet方法时,工作线程的threadLocals变量里面被添加了LocalVariable实例,但是后来没有清除。另外多次访问该Servlet可能使用的不是工作线程池里面的同一个线程,这会导致工作线程池里面多个线程都会存在内存泄漏问题。

  • 小结

    Java提供的ThreadLocal给我们编程提供了方便,但是如果使用不当也会给我们带来麻烦,所以要养成良好的编码习惯,在线程中使用完ThreadLocal变量后,要记得及时清除掉。

posted on 2022-08-26 19:12  小迟在努力  阅读(79)  评论(0)    收藏  举报