文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

接口从4秒到200毫秒-小小的日志竟能引发如此问题

2024-11-28 15:27

关注

2 问题验证

在测试环境中维护某渠道质检项与内部质检项映射关系15000+;

发现模版质检转换接口耗时4s左右;

图片图片

图片

将质检项的映射关系减少到100+时;

模版质检转换接口耗时到了100毫秒左右。

图片图片

图片图片

发现模版映射接口时长确实与映射项多少有很强的关联性。

3 问题初步定位

通过阿里的Arthas工具的trace命令可以分析方法的耗时。

图片图片

经观察,该方法的耗时主要集中于打印日志。查看代码得知,在将外部质检项映射为内部质检项的过程中会产生大量日志,且映射关系越多,日志数量越多。因项目急于上线,故先对打印日志的代码进行注释,随后发现接口的响应速度明显变快。

4 初步思考

4.1 有没有可能日志打印配置的是同步打印?

首先考虑是否为同步打印日志,毕竟同步读写文件会涉及磁盘随机读写,进而影响效率。经查看 log4j 的配置,得知打印日志的配置为异步打印,而非同步打印。

4.2 既然是异步打印,有没有可能是多线程争抢资源导致打印日志过慢呢?

排除同步打印日志的原因后,由于起初并不了解异步日志打印的底层实现。但依据经验,因需异步打印日志,需有地方存储需要打印的日志,故而可能涉及资源争抢。基于此想法,创建一个测试方法,进行单线程循环打印日志2万次操作。

@Test
private void test4(){
    long l = System.currentTimeMillis();
    for (int i = 0; i < 20000; i++) {
      log.info(i+"");
    }
    System.out.println("打印日志耗时:" + (System.currentTimeMillis() - l));
}

发现打印日志耗时为 600 多毫秒。这就感觉很奇怪,如果仅仅存储 20000 个需要打印的日志任务需要这么长时间吗?

于是,搞了一个线程池,提交20000个任务也查看一下耗时;

public static void main(String[] args) {
        long l = System.currentTimeMillis();
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10, 10, 60, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(20000));
        for (int i = 0; i < 20000; i++) {
            threadPoolExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    
                }
            });
        }
        System.out.println("打印耗时:" + (System.currentTimeMillis() - l));
    }

发现只需要2,30毫秒。

在单线程的环境中,若打印大量日志,速度也极为缓慢,由此可见,并非是多线程的问题所致。

我们都清楚,线程池在提交任务时,若队列已满且达到最大线程数,便会执行抛弃策略,其中有一种是将任务交回给提交任务的线程执行。那么在异步日志打印过程中,倘若需打印的日志过多,使得存储需要异步打印日志的地方满了,是否也存在相应的抛弃策略呢?比如将异步打印转为同步打印。所以,我决定先去探究异步打印日志的原理。

5 异步日志打印原理

5.1 Disruptor原理

经过查询资料发现,异步日志打印的底层采用的是Disruptor框架。

图片图片

其基本原理为生产者向环形数组中存入数据,消费者则消费环形数组中的数据。sequence 用于限制生产者的生产以及消费者的消费。通过阅读代码得知,本质上生产者会保存所有消费者以及自身的 sequence 以限制生产,消费者保存生产者来限制消费。

//org.apache.logging.log4j.core.async.AsyncLoggerConfigDisruptor#start
public synchronized void start() {
  //....
  //
  disruptor.handleEventsWith(handlers);
  //....
}

//com.lmax.disruptor.dsl.Disruptor#handleEventsWith(com.lmax.disruptor.EventHandler...)
//添加事件处理(消费者)
public final EventHandlerGroup handleEventsWith(final EventHandler... handlers) {
  return createEventProcessors(new Sequence[0], handlers);
}

//com.lmax.disruptor.dsl.Disruptor#createEventProcessors(com.lmax.disruptor.Sequence[], com.lmax.disruptor.EventHandler[])
//创建事件处理(消费对象)
EventHandlerGroup createEventProcessors(
  final Sequence[] barrierSequences,
  final EventHandler[] eventHandlers) {
  //....
  //所有消费者的Sequence
  final Sequence[] processorSequences = new Sequence[eventHandlers.length];
  //将生产者包装成为SequenceBarrier
  final SequenceBarrier barrier = ringBuffer.newBarrier(barrierSequences);
  //遍历事件处理
  for (int i = 0, eventHandlersLength = eventHandlers.length; i < eventHandlersLength; i++) {
    final EventHandler eventHandler = eventHandlers[i];
    //创建事件处理(消费者)
    final BatchEventProcessor batchEventProcessor =
      new BatchEventProcessor<>(ringBuffer, barrier, eventHandler);
    //....
    //将消费者的Sequence放入gatingSequences中
    processorSequences[i] = batchEventProcessor.getSequence();
  }
  //更新生产者的processorSequences
  updateGatingSequencesForNextInChain(barrierSequences, processorSequences);
  //....
}

5.2 异步日志放入Disruptor环形数组的过程

图片图片

代码

//org.apache.logging.log4j.core.async.AsyncLoggerConfig#logToAsyncDelegate
private void logToAsyncDelegate(final LogEvent event) {
  if (!isFiltered(event)) {
    // Passes on the event to a separate thread that will call
    // asyncCallAppenders(LogEvent).
    populateLazilyInitializedFields(event);
  //尝试将logevnet对象发布到环形数组中
    if (!delegate.tryEnqueue(event, this)) {
      //如果没有放进去,说明队列满了,执行队列满的处理方法,本质上类似于线程池队列满丢弃策略
      //ENQUEUE:将日志放入环形数组(放入不进去就循环等待,一直到放进去)
      //SYNCHRONOUS:当前线程打印日志
      //DISCARD:直接丢弃,忽略日志
      handleQueueFull(event);
    }
  }
}
//org.apache.logging.log4j.core.async.AsyncLoggerConfigDisruptor#tryEnqueue
public boolean tryEnqueue(final LogEvent event, final AsyncLoggerConfig asyncLoggerConfig) {
    final LogEvent logEvent = prepareEvent(event);
    //获取环形数组
    return disruptor.getRingBuffer().
      //放入环形数组中
      tryPublishEvent(translator, logEvent, asyncLoggerConfig);
}
//com.lmax.disruptor.RingBuffer#tryPublishEvent(com.lmax.disruptor.EventTranslatorTwoArg, A, B)
//尝试放入环形数组中
public  boolean tryPublishEvent(EventTranslatorTwoArg translator, A arg0, B arg1){
    try
    {
      //尝试获取生产者下一个要存放到环形数组中的位置
      final long sequence = sequencer.tryNext();
      //将其放入指定的位置中
      translateAndPublish(translator, sequence, arg0, arg1);
      return true;
    }
    catch (InsufficientCapacityException e)
    {
      //容量不足,就返回fals,执行环形数组满的处理方法
      return false;
    }
}
//com.lmax.disruptor.MultiProducerSequencer#tryNext(int)
//获取生产者下一个位置,n表示当前位置+1,tryNext()就是tryNext(1)
public long tryNext(int n) throws InsufficientCapacityException {
    if (n < 1) {
      throw new IllegalArgumentException("n must be > 0");
    }

    long current;
    long next;

    do {
      //获取当前位置
      current = cursor.get();
      //想要获取的位置
      next = current + n;
      //判断是否有可用容量
      //实现原理大概就是比较Sequence(消费者消费到哪里了)和current中的较小值,与next差值是否大于环形数组的大小
      if (!hasAvailableCapacity(gatingSequences, n, current)) {
        throw InsufficientCapacityException.INSTANCE;
      }
    }
    while (!cursor.compareAndSet(current, next));
    return next;
}

发现确实有类似于线程池的抛弃策略。

5.3 复用4.2中打印日志的测试代码进行打断点

经过调试发现,在日志打印的过程中消费者消费快,没有出现handleQueueFull的调用。

所以并不会经过ENQUEUE、SYNCHRONOUS、DISCARD的三种策略的处理。

那么现在问题就变得十分离谱,并非上述原因所致。因此,决定采用暴力破解之法,即逐行注释代码的方式,以查看究竟是哪一行代码引发了日志打印缓慢的问题。

6 将日志打印过程中的代码进行逐行注释,找到那一行导致日志输出慢

6.1 定位原因发现是getLocation方法

经过逐行注释后,确定是由于getLocation方法所致。那么这行代码究竟有何作用呢?

查询资料得知getLocation 是用来获取内容匹配日志输出格式中的%C、%F、%l、%L、%M 。

以下是该方法的调用之处以及其内部代码。

//org.apache.logging.log4j.spi.AbstractLogger#logMessageTrackRecursion
private void logMessageTrackRecursion(final String fqcn,
                                      final Level level,
                                      final Marker marker,
                                      final Message message,
                                      final Throwable throwable) {
  try {
    incrementRecursionDepth(); // LOG4J2-1518, LOG4J2-2031
    tryLogMessage(fqcn, getLocation(fqcn), level, marker, message, throwable);
  } finally {
    decrementRecursionDepth();
  }
}
//org.apache.logging.log4j.spi.AbstractLogger#getLocation
//获取日志是哪个类,哪个方法,哪一行打印的
//举例[11-05 00:03:39 596 INFO ] [] [] [] [main] [] c.z.h.p.r.r.c.ChannelCommonFacadeTest.main(383) - 111
//中的c.z.h.p.r.r.c.ChannelCommonFacadeTest.main(383)字符串
private StackTraceElement getLocation(String fqcn) {
  //requiresLocation 用于判断是否需要Location,不需要直接返回null
  return requiresLocation() ? StackLocatorUtil.calcLocation(fqcn) : null;
}

继续向下追踪会到下方代码:

//org.apache.logging.log4j.util.StackLocator#calcLocation
public StackTraceElement calcLocation(final String fqcnOfLogger) {
    //....
   //fqcnOfLogger=org.apache.logging.slf4j.Log4jLogger
    //获取当前方法调用链路上所有的调用链路
    final StackTraceElement[] stackTrace = new Throwable().getStackTrace();
    boolean found = false;
    for (int i = 0; i < stackTrace.length; i++) {
      final String className = stackTrace[i].getClassName();
      //判断类名称是否相同
      if (fqcnOfLogger.equals(className)) {

        found = true;
        continue;
      }
      //这样就得到了谁调用的日志输出了
      if (found && !fqcnOfLogger.equals(className)) {
        return stackTrace[i];
      }
    }
    return null;
}
//java.lang.Throwable#getStackTrace
public StackTraceElement[] getStackTrace() {
 return getOurStackTrace().clone();
}
//java.lang.Throwable#getOurStackTrace
private synchronized StackTraceElement[] getOurStackTrace() {
  // Initialize stack trace field with information from
  // backtrace if this is the first call to this method
  if (stackTrace == UNASSIGNED_STACK ||
      (stackTrace == null && backtrace != null) ) {
    //获取调用链路的长度
    int depth = getStackTraceDepth();
    stackTrace = new StackTraceElement[depth];
    for (int i=0; i < depth; i++)
      //获取每一级调用链路
      stackTrace[i] = getStackTraceElement(i);
  } else if (stackTrace == null) {
    return UNASSIGNED_STACK;
  }
  return stackTrace;
}
//方法都是本地方法
native int getStackTraceDepth();
native StackTraceElement getStackTraceElement(int index);

通过上述跟踪源码,怀疑是Java调用C++代码或者需要遍历StackTraceElement数组导致异步日志打印过慢。

所以先将getOurStackTrace方法进行修改,不调用C++方法,直接创建对象,来排除不是因为遍历数组导致的原因。

//org.apache.logging.log4j.util.StackLocator#calcLocation
public StackTraceElement calcLocation(final String fqcnOfLogger) {
    //....
   //fqcnOfLogger=org.apache.logging.slf4j.Log4jLogger
    //将这里改为自己的写的方法调用,构建方法调用链路
    final StackTraceElement[] stackTrace = stackTraceElements(fqcnOfLogger);
    boolean found = false;
    for (int i = 0; i < stackTrace.length; i++) {
      final String className = stackTrace[i].getClassName();
      //判断类名称是否相同
      if (fqcnOfLogger.equals(className)) {

        found = true;
        continue;
      }
      //这样就得到了谁调用的日志输出了
      if (found && !fqcnOfLogger.equals(className)) {
        return stackTrace[i];
      }
    }
    return null;
}

private StackTraceElement[] stackTraceElements(String fqcnOfLogger) {
    //构建一个42长度的,保持测试代码获取相同长度的方法调用链路
    int size = 42;
    StackTraceElement[] stackTraceElement = new StackTraceElement[size];
    for (int i = 0; i < size - 2; i++) {
      stackTraceElement[i] = new StackTraceElement("1", "1", "1", 1);
    }
    stackTraceElement[size - 2] = new StackTraceElement(fqcnOfLogger, "1", "1", 1);
    stackTraceElement[size - 1] = new StackTraceElement("1", "1", "1", 1);
    return stackTraceElement;
}

//然后再执行代码测试代码
@Test
private void test4(){
    long l = System.currentTimeMillis();
    for (int i = 0; i < 20000; i++) {
      log.info(i+"");
    }
    System.out.println("打印日志耗时:" + (System.currentTimeMillis() - l));
}

发现耗时只需要50毫秒,所以由此怀疑是Java调用C++代码导致异步日志打印过慢。Java调用C++代码用的框架叫JNI。

6.2 官方资料

图片图片

这段文字说明使用堆栈跟踪比不实用堆栈跟踪慢30-100倍。

图片图片

这张图用来说明单线程异步日志记录时需要记录调用位置信息与不需要记录调用位置信息的对比。

6.3 JNI原理

JNI调用Java代码是一种类似反射的原理,先找到jclass、再找到jmethodId,然后调用,这样一步步地来;

Java调用C/C++代码创建对象是需要保存对象指针,然后各种操作是要将指针传入到jni层,然后强转到具体对象再进行操作的。

6.3.1 JNI 架构

JNI 的基本架构可以分为以下几个部分:

6.3.2 数据类型转换

JNI 负责在 Java 数据类型和 C/C++ 数据类型之间进行转换。常见的数据转换方法包括:

6.3.3 异常处理

JNI 提供异常处理机制,允许本地代码检测和抛出 Java 异常。通过 env->ExceptionCheck() 和 env->Throw 方法,可以在本地代码中检查异常状态并抛出异常。

6.3.4 线程管理

JNI 允许多线程访问 JVM,提供了与线程相关的 API,如 AttachCurrentThread 和 DetachCurrentThread,用于在本地代码中管理 Java 线程。

6.3.5 性能考虑

6.4 编写本地方法测试

public class Hello {
    public native int helloFromCpp(int i);

    static {
       //加载动态链接
        System.load(System.getProperty("user.dir") +"/libnative.dylib");
    }
}

下面是如何能进行本地方法调用的过程:

  1. javac Hello.java -h . 将Java文件编译为.class并产生头文件。
  2. 产生的头文件的展示

#include 


#ifndef _Included_test_Hello
#define _Included_test_Hello
#ifdef __cplusplus
extern "C" {
#endif

//jni产生的方法名称是有规则的 Java开头 test表示包 Hello表示类 helloFromCpp表示方法
JNIEXPORT jint JNICALL Java_test_Hello_helloFromCpp
  (JNIEnv *, jobject, jint);

#ifdef __cplusplus
}
#endif
#endif

#include 
#include "test_Hello.h"




JNIEXPORT jint JNICALL Java_test_Hello_helloFromCpp
  (JNIEnv * env, jobject o , jint j1){
    return j1;
  }
public static void test() {
  long l = System.currentTimeMillis();
  for (int i = 0; i < 20000*32; i++) {
  Hello h = new Hello();
  h.helloFromCpp(i);
  }
  System.out.println("耗时时间" + (System.currentTimeMillis() - l));
}

注意:苹果电脑要是arm架构的,一定要注意执行Java程序的JDK版本要是arm架构的。

发现耗时很少,所以并不是因为Java通过JNI执行C语言代码的问题。

所以最终结论应该是底层执行C++代码慢的缘故。

7 结论

8 扩展(想了解C底层实现)

可以查看一下下面三个类:

来源:转转技术内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯