在 Java IO 中,最为核心的一个概念是流(Stream),面向流的编程,一个流要么是输入流,要么是输出流,不可能同时既是输入流又是输出流。
在 Java NIO 中,我们是面向块(block)或是缓冲区(buffer)编程的。Buffer本身就是一块内存,数据的读写都是通过 Buffer 来实现的,还提供了对于数据的结构化访问方式,并且可以追踪到系统的读写过程。
NIO 的核心是由 Channel
、Buffer
、Selector
三部分组成。
你可以将 Channel 理解成 IO 中的 Stream,数据可以从 Channel 读到 Buffer 中,也可以从 Buffer 写到 Channel 中【对 channel 的读写操作,必须要通过 Buffer 来进行,不能直接从 Channel 读写数据】。
和 Stream 相比又有一些不同:
常见的 Channel 实现有以下几种:
FileChannel
: 从文件中读写数据DatagramChannel
: 能通过 UDP 读写网络中的数据SocketChannel
: 能通过 TCP 读写网络中的数据ServerSocketChannel
: 可以监听新进来的 TCP 连接,像 Web 服务器那样。对每一个新进来的连接都会创建一个 SocketChannelJava中的七种原生数据类型都有各自对应的 Buffer 类型,如 IntBuffer、ByteBuffer 等(除 BooleanBuffer)。使用 Buffer 读写数据一般遵循以下四个步骤:
flip()
方法clear()
方法或者 compact()
方法当向 Buffer 写入数据时,Buffer 会记录下写了多少数据。一旦要读取数据,需要通过 flip()
方法将 Buffer 从写模式切换到读模式。在读模式下,可以读取之前写入到 Buffer 的所有数据。
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用 clear()
或 compact()
方法。
clear()
方法会清空整个缓冲区compact()
方法只会清除已经读过的数据,任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面Selector 允许单个线程处理多个 Channel。如果你的应用同时建立了多个客户端连接,但是每个连接流量都很低(例如聊天服务器),使用 Selector 就能最大程度提高性能。
要使用 Selector,得向 Selector 注册 Channel,然后调用它的 select()
方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子有如新连接进来,数据接收等。
Buffer 中有三个属性最为重要:position
, limit
, capacity
,首先给个结论:0 <= position <= limit <= capacity。
Buffer 的 capacity 在初始化时就被确定了,并且无法修改。你只能往里写 capacity 个 byte, long, char 等类型。一旦 Buffer 满了,需要将其清空(通过读数据或者清除数据)才能继续往里写数据。
当写模式时,position 表示当前写入的位置。初始的 position 值为 0,最大值为 。当一个数据写到 Buffer 后,position 会向前移动到下一个可插入数据的 Buffer 单元。
当读模式时,position 表示当前读取的位置。当将 Buffer 从写模式切换到读模式,position 会被重置为0。当从 Buffer 的 position 处读取数据时,position 向前移动到下一个可读的位置。
当写模式时,limit 表示你最多能往 Buffer 里写多少数据,等于 capacity。当切换到读模式时,limit 会被设置成写模式最后的 position 值,表示你最多能读到多少数据。
每一个 Buffer 类都有一个 allocate()
方法,方法参数需要指定 capacity。
1 | ByteBuffer buf = ByteBuffer.allocate(48); // 分配48字节capacity的ByteBuffer |
读写数据,既可以操作 Channel 的 API,也可以操作 Buffer 的 API:
1 | int bytesRead = inChannel.read(buf); // 将数据从 Channel 写入到 Buffer 中 |
切换 Buffer 的读写模式时,需要调用 flip()
方法:会将 position 设回 0,并将 limit 设置成之前 position 的值。
1 | int bytesWritten = inChannel.write(buf); // 从 Buffer 读取数据到 Channel 中 |
其中 Buffer 的 get() 和 put() 方法有许多重载方法,可以读取/写入特定数据类型的数据,或是操作指定 position 的数据,具体见 JavaDoc。
该方法会将 position 设回 0,limit 保持不变:实现 Buffer 中数据的重新读取。
当读完 Buffer 中的数据后,需要让 Buffer 准备好再次被写入。可以通过 clear()
或 compact()
方法来完成。
如果调用的是 clear()
方法:position 将被设回 0,limit被设置成 capacity 的值。
如果调用的是 compact()
方法:将所有未读的数据拷贝到 Buffer 起始处。然后将 position 设到最后一个未读元素正后面。limit属性设置成capacity。
由此可见:
通过调用 mark()
方法,可以标记 Buffer 中的一个特定 position
,之后可以通过调用 reset()
方法恢复到这个position。例如:
1 | buffer.mark(); |
满足以下条件时,表示两个 buffer 相等:
注:其中未消费数据指 position 到 limit 之间的元素
满足以下条件时,则认为一个 Buffer 小于另一个 Buffer:
使用 Channel 进行读操作时,可以将读取到的数据,写入到多个 Buffer 中,这个操作称之为 Scatter
。
相对应的,使用 Channel 进行写操作时,可以从多个 Buffer 中,读取到要写入的数据,这个操作称之为 Gather
。
一种使用场景时,当我们自定义消息协议时,比如消息头部分由 5 个字节组成,消息体由 10 个字节组成,为了区分消息体和消息头的内容:
1 | ByteBuffer header = ByteBuffer.allocate(5); |
read()
方法按照 Buffer 在数组中的顺序将从 channel 中读取的数据写入到 buffer,当一个 buffer 被写满后,channel 紧接着向另一个 buffer 中写。
Scattering Reads 在移动下一个buffer前,必须填满当前的 buffer,这也意味着它不适用于动态(即长度不固定)消息。
write()
方法会按照 Buffer 在数组中的顺序,将数据写入到 channel,注意只有 position 和 limit 之间的数据才会被写入。因此,如果一个buffer的容量为 128byte,但是仅仅包含 58byte 的数据,那么这 58byte 的数据将被写入到 channel 中。因此与 Scattering Reads 不同,Gathering Writes 能较好的处理动态消息。
1 | ByteBuffer header = ByteBuffer.allocate(5); |
在本篇文章中,一起来学习下 SBE(Simple Binary Encoding) 传输协议,它和 protobuf 一样,都是二进制的传输协议,比 protobuf 传输性能更高,其涉及灵感来源于 fast binary variant of FIX,最初的涉及目标就是为了应用于金融级的低延迟交易系统中。在开源软件 Aeron 中,也广泛的使用 SBE 作为数据的传输媒介。
网络和存储系统处理数据时通常需要对数据缓冲区进行编码和解码。Copy-Free 的原则是不使用任何中间缓冲区来编码和解码数据。如果使用了中间缓存区,会因为多次的复制数据产生性能损耗。
SBE 采用直接对底层缓冲区进行编码和解码的方式,这样带来的限制是不支持直接发送长度大于传输缓冲区的数据,对于这种情况,需要进行分段发送和数据重组。
Copy-Free 模式通过将数据直接编码为底层缓冲区中的本地类型而得到性能提升。比如 64 位整数可以作为单个 x86_64 MOV 指令直接编码到底层缓冲区中。如果数据的字节序(大端/小端)和 CPU 的不一致的化,那么数据在写入底层缓冲区前可以在寄存器中使用 x86 的 BSWAP 指令完成交换。
对象的创建会导致 CPU 的缓存减少,从而降低效率。并且后期还需要去管理并释放这些对象。对于 Java 来说,这个过程是由垃圾收集器完成的,它通过触发持续时间不等的 STW(Stop The World)来完成内存回收(新生代的 C4 垃圾收集器除外)。C++ 相对好点,但当内存释放回内存池的时候引入锁机制,也会产生性能开销。
SBE 编解码器使用了享元(flyweight)模式,来避免分配问题。flyweight 窗口在底层缓冲区上直接对数据进行编码和解码,通过消息头中的 templateId 字段来选择适当类型的 flyweigh。如果消息中的字段需要保留岛处理流程之外,则需要单独复制出来。
1 | public class ShapeFactory { |
现代内存子系统已经变得愈发复杂,访问内存的算法模式很大程度上决定了性能和一致性。采用基于流的方式以升序模式访问内存地址,可以获得最佳性能和最一致的延迟。
SBE 编解码器根据底层缓冲区中 position 的向前递进对数据进行编码和解码。虽然可以进行一定程度的回溯,但从性能和延迟的角度来看这种操作是非常不鼓励的。
当我们不在 word 的边界位置访问数据时,许多 CPU 架构会表现出显著的性能问题。一个 word 的起始地址应该是其以字节为单位大小的倍数,例如 64 位整数只能从字节地址能被 8 整除的地址开始,32位整数只能从被 4 整除的字节地址开始,以此类推。
SBE 模式支持定义数据中字段起始位置的偏移量(offset)的概念。它假设数据是被封装在 8 字节大小边界的协议帧中。为了实现紧凑与高效,消息字段应该按其类型和大小降序排序。
1 | typedef struct _tcp_hdr |
消息格式必须能够向后兼容,即旧系统应该能够读取同一消息的较新版本,反正亦然。
SBE 中设计了一种扩展机制,允许在数据中引入新的可选字段,新系统可以使用这些字段,而旧系统在升级之前会忽略这些字段。如果需要更改必填字段或基本结构,则必须使用新的消息类型,因为这已不再是现有数据类型的语义扩展。
首先我们得学会如何定义一个 SBE 的传输协议,它不同于 protobuf 使用自定义格式,SBE 采用 XML 格式。具体还是得参考官方文档,这里我就不做 CV 侠了。
当我们准备好了 SBE 的 XML 文件后,下一步就是需要根据该文件生成对应的编解码器代码,官方推荐的是使用 sbe-all-${SBE_LIB_VERSION}.jar
工具包,然后调用 java -jar
的方式生成代码,具体的流程以及详细的参数见官方文档。
这里我介绍下另一种通过 Maven 插件的方式生成代码,用起来更为方便。首先假设我将 XML 文件存放在项目的 resources
目录下,如下图所示。
然后在 Maven 中添加插件:
1 | <build> |
然后执行 maven clean install
命令后自动生成代码会放置在 target/generated-sources/java
目录下。
注意:如果需要调整 XML 文件位置,或者生成文件的位置,或者生成参数,请自行调整上文插件中的具体参数。
下一步就是利用生成的编解码类,进行数据传输了,难点就是对它 API 的使用了,这里我给出两个我学习时候用的例子大家在本地对照文档进行 Debug,很快就明白了。
第一个例子来源于 Aerona Cookbook:
第二个例子来源于 SBE GitHub:
Agrona
是 real-logic 开发的一个 Java 工具包,它提供了许多高性能的数据结构和工具方法,主要包括:Duty Cycle
是一种编程模型,它是一个死循环程序,在循环中,去执行某个逻辑,并根据执行结果去决定是否要等待一会进行下次循环。例如:
1 | while (true) { |
在 Agrona 中,定义了 Agents
:
1 | public interface Agent { |
doWork()
,用于处理业务逻辑,它的返回值用于决定在 Agrona 中是否执行空闲策略:
doWork()
除此之外,onStart()
和 onClose()
作为 Agent 启动和关闭时的回调钩子方法,roleName()
则申明了该 Agent 的名字。
Agrona 原生提供了一些空闲策略:
Name | Implementation Details |
---|---|
SleepingIdleStrategy | 基于 parkNanos 实现线程暂停 |
SleepingMillisIdleStrategy | 基于 thread.sleep 实现线程暂停,适合在低配置机器上进行本地开发或使用大量进程进行开发 |
YieldingIdleStrategy | 使用 thread.yield 让出对线程的控制 |
BackoffIdleStrategy | 一种激进的策略,先使用 spinning 再使用 yield(Thread.yield() ),最后再根据配置的时间 parkNanos,这是 Aeron Cluster 默认的策略 |
NoOpIdleStrategy | 最为激进的策略,不做任何处理 |
BusySpinIdleStrategy | 对于 Java 9 及以上版本,将会使用 Thread.onSpinWait() 。这向 CPU 提供了一个提示,即线程处于紧密循环中但忙于等待某事,然后 CPU 可能会在不涉及 OS 调度程序的情况下将额外资源分配给另一个线程。 |
如果需要自定义空闲策略,仅需要实现 IdleStrategy
接口即可:
1 | public interface IdleStrategy { |
上面的空闲策略并不一定保证线程安全,因此建议每个 Agent 使用独立的空闲策略
Agent Runner
则负责将 Agent 和 Idle Strategies 组合并运行起来:
1 | final AgentRunner runner = new AgentRunner(idleStrategy, errorHandler, errorCounter, agent); |
上面是 AgentRunner 的构造方法,其中:
参数 | 含义 |
---|---|
idleStrategy | 空闲策略实例对象 |
errorHandler | Agent 执行过程中出现异常时的回调处理器 |
errorCounter | 记录 Agent 执行过程中出现异常的次数 |
agent | Agent 实例对象 |
得到 AgentRunner 对象后,Agrona 提供以下三种方式来真正启动:
AgentRunner#startOnThread(AgentRunner)
,执行后会创建一个线程来运行AgentRunner#startOnThread(AgentRunner, ThreadFactory)
,会使用指定的 threadFactory 来创建独立线程CompositeAgent
,然后调用上面两种方式,这些 Agent 将会公用一个线程来运行AgentRunner 的特点是当启动后,就会自动的执行,如果我们想手动控制 Agent 的运行,Agrona 提供了 AgentInvoker
:
1 | final AgentInvoker agentInvoker = new AgentInvoker(errorHandle, errorCounter, agent); |
可以看到构造方法相较于 AgentRunner 去掉了空闲策略,因为是 Agent 是需要手动执行的,所以不需要这个参数。
Agrone 提供了一套自己的 Clock API,首先它是基于 Epoch Time,也就是自 1970-1-1 00:00:00.000 到现在的时间差。顶层接口是 EpochClock
,有种实现:SystemEpochClock
和 CachedEpochClock
。
对于 SystemEpochClock
,返回的是毫秒时间差,其实就是对 System.currentTimeMillis()
的封装,提供了一个静态实例用于操作:
1 | EpochClock clock = SystemEpochClock.INSTANCE; |
对于 CachedEpochClock
,它其实就是一个缓存,主要有以下几个方法:
1 | CachedEpochClock clock = new CachedEpochClock(); |
另外,Agrone 还提供了微秒和纳秒级的 API:
SystemEpochMicroClock
基于 java.time.Instant
API 实现SystemEpochNanoClock
基于 java.time.Instant
API 实现OffsetEpochNanoClock
以定时采样的方式调用 System.nanoTime()
API,可以根据需要调整采样间隔和参数Aeron
的作者在 LMAX 任职期间,开发了 disruptor,点击这里查看相关文章。在 Agrona 中,作者也提供了这种数据结构的支持。
适用于单生产者单消费者的场景,和 Disruptor 中的 RingBuffer 不同,在定义 RingBuffer 大小时需要额外添加 RingBufferDescriptor.TRAILER_LENGTH
,ByteBuffer
API 决定在堆内还是堆外分配缓冲区。
下面代码中,展示了创建一个大小为 4096 的 OneToOneRingBuffer,采用堆外分配缓冲区:
1 | final int bufferLength = 4096 + RingBufferDescriptor.TRAILER_LENGTH; |
消费数据时,需要实现 MessageHandler
接口,例如:
1 | public class MessageCapture implements MessageHandler { |
其中 msgType
字段是消息的标识,会存储在消息头中。如果不用这个字段的话,必须设置为大于 0 的值。
生产数据时,需要调用 RingBuffer 的 write
方法,例如:
1 | //prepare some data |
sentOk
表示写入是否成功,利用这个可以进行背压操作,防止消费者消费不过来。RingBuffer 提供了下面两个方法来展示当前的生产和消费情况:
1 | //the current consumer position in the ring buffer |
除了 MessageHandler 接口外 ControlledMessageHandler
也能够实现对 RingBuffer 的消费:
1 | public class ControlledMessageCapture implements ControlledMessageHandler { |
不同之处在于 onMessage()
方法返回 ControlledMessageHandler.Action
:
写入数据时,也可以通过 tryClaim()
方法可以直接操作 RingBuffer 底层的数据结构,如果使用常规的 write()
方法,需要把数据在对象中机械能拷贝,使用这种方式能省下拷贝的开销。
1 | int claimIndex = ringBuffer.tryClaim(1, Integer.BYTES); |
首先,调用 tryClaim()
以获取可以写入的索引,然后获取 RingBuffer 的底层数据结构,向其中写入数据最后,调用 commit
或 abort
结束。
API 和 OneToOneRingBuffer 一致,支持多生产者的场景。
OneToOneRingBuffer 和 ManyToOneRingBuffer 都是在单消费者的场景,如果需要多消费者,Agrona 提供了 BroadcastTransmitter
和 BroadcastReceiver
。
需要特别注意的是,在 Broadcast 下,如果发送方的生产速度快于消费者的消费能力,消息会被丢弃(没有背压支持)。
1 | private final BroadcastTransmitter transmitter; |
1 | public class ReceiveAgent implements Agent, MessageHandler { |
Agrona 提供了许多集合数据结构,用于解决基础数据类型在集合中需要装箱拆箱的开销。
使用 IDE 进行 DEUBG 时,Agrona HashMaps 可能会出现其中元素错误的问题。为了解决可以将构造方法中的 shouldAvoidAllocation
设置为 false, Agrona 将会关闭缓存功能,但这也会导致 GC 的增加。
Collection | Notes |
---|---|
Int2IntHashMap | <int, int> 的 HashMap |
Int2NullableObjectHashMap | <int, nullable object> 的 HashMap,如果 value 为 null 的话,在集合内部会使用 NullReference 来标识。 |
Int2ObjectHashMap | <int, object> 的 HashMap |
Long2LongHashMap | <long, long> 的 HashMap |
Long2NullableObjectHashMap | <long, nullable object> 的 HashMap,如果 value 为 null 的话,在集合内部会使用 NullReference 来标识。 |
Long2ObjectHashMap | <long, object> 的 HashMap |
Object2IntHashMap | <object, int> 的 HashMap |
Object2NullableObjectHashMap | <object, nullable object> 的 HashMap |
Object2ObjectHashMap | <object, object> 的 HashMap |
在使用这些 HashMaps 时,需要确保元素 hashCode()
的正确性,另外如果 hashCodes 冲突严重也会极大影响集合的性能。
Collection | Notes |
---|---|
Int2ObjectCache | Cache with primitive int lookup to an object. Tuned for very small data structures stored within CPU cache lines. Typical sizes are 2 to 16 entries. Underlying storage is an array. |
IntLruCache | 固定大小的缓存,当达到上限时,使用 LRU 策略清理过期缓存 |
Collection | Notes |
---|---|
IntHashSet | 基础 int 类型的 HashSet,自动扩容。 |
ObjectHashSet | object 类型的 HashSet,自动扩容。 |
Collection | Notes |
---|---|
IntArrayList | 基础 int 类型的 ArrayList |
IntArrayQueue | 基础 int 类型的 ArrayQueue |
BiInt2ObjectMap | 将两个 int 类型组合成一个 key,value 为 object 的 Map |
Agrona 由于使用了 sun.misc.Unsafe
和 sun.nio.ch.SelectorImpl.selectedKeys
API,导致 JVM 在启动时可能有打印关于非法反射访问的警告日志。如果要删除的化,添加 JVM 参数:--add-opens java.base/sun.nio.ch=ALL-UNNAMED --add-opens jdk.unsupported/sun.misc=ALL-UNNAMED
Agrona 定义了 DirectBuffer
接口在用于和 Aeron 交互,它有点类似于 Java NIO ByteBuffer,但更方便一些。
主要有以下三种实现:
Name | Implementation Details |
---|---|
UnsafeBuffer | 堆外固定大小缓冲区,当超出大小时,会抛出 IndexOutOfBoundsException 异常。 |
ExpandableDirectByteBuffer | 可扩容的直接缓冲区,底层使用 ByteBuffer 实现,默认为 128 字节,通过构造方法可以调整大小。当超出大小时,会创建一个新的 ByteBuffer 并将现有内容拷贝进去。 |
ExpandableArrayBuffer | 底层使用字节数组(new byte[size] )的直接缓冲区,默认为 128 字节,当超出大小时,会创建一个新的 byte[] 并将现有内容拷贝进去。 |
Agrona 默认使用的字节序为 ByteOrder.nativeOrder()
的字节序,读写使用不同的字节序,会导致错误的结果。这可能出现在跨操作系统和跨平台的交互中。
下图是一个大小为 13 个字节的缓冲区,如果想要从中提取高亮部分的 4 个字节,我们需要先将 offset 设置为 4,再将读取长度设置为 4。
DirectBuffer 提供了读写单个字节或 16 位字符的方法。
DirectBuffer 提供了对 short、int、long 型数据的读写支持。对于 int 和 long,还额外提供了 compare-and-set、get-and-add、get-and-set 的工具方法。
1 | //place 41 at index 0 |
1 | //read current value while writing a new value. |
1 | //check the value was what was expected, returning true/false if it was. Then update the value a new value |
通常不推荐使用 float 和 double 进行数据传输,要么使用格式化为字符串的 BigDecimal,要么使用缩放后的 long。
DirectBuffer 提供了读写 float 和 double 的方法。
putStringAscii
、putStringUtf8
操作非固定长度字符串,效率较低
putStringWithoutLengthAscii
、putStringWithoutLengthUtf8
操作固定长度字符串
Agrona 也实现了雪花算法:
IllegalStateException
异常初始化雪花算法时,需要提供一个唯一的节点 ID,默认情况下最多支持 1024 个节点。
1 | final long nodeId = 1L; |
需要注意的是,默认情况下它使用 1970 年作为起点,因此最多只能生成到 2039 年(和 Epoch Time 类似)。它提供了一个重载的构造方法,用于指定起始的时间。
]]>我们知道,如果想要使用 Mockito,要么需要在测试类的 @Before,执行 org.mockito.MockitoAnnotations#initMocks
,要么在测试类上添加 @RunWith(MockitoJUnitRunner.class)
注解。下面就让我们从 MockitoAnnotations#initMocks 方法看起吧。
initMocks() 方法一共就两行代码:加载 AnnotationEngine,调用 process() 方法,传入的 testClass 为需要启用 Mockito 的测试类:
先来看下 AnnotationEngine 的获取逻辑:
当 GlobalConfiguration 实例化时,调用 createConfig() 方法,得到 IMockitoConfiguration 实现。只要没有进行额外配置,这里的实现是 DefaultMockitoConfiguration
调用 GlobalConfiguration#tryGetPluginAnnotationEngine 得到 AnnotationEngine。对于默认实现(DefaultMockitoConfiguration )来说,直接从 Plugins 读取实现即可:
1 | org.mockito.internal.configuration.InjectingAnnotationEngine |
Plugins 的源码追踪过程就不赘述了,前文在分析 MockerMaker 时已经带大家走过一次了。
接下来让我们看 process 方法,功能也比较清晰:
processIndependentAnnotations()
初始化 @Mock、@Spy 等注解标识的对象processInjectMocks()
尝试将 Mock 对象注入到 @InjectMocks 中这个方法的功能是:
IndependentAnnotationEngine#process
方法,初始化 @Mock 等注解标识的对象SpyAnnotationEngine#process
方法,初始化 @Spy 等注解标识的对象createMockFor()
方法setField()
方法,将生成的对象赋上去① createMockFor() 方法实现如下:
下面以 @Mock 注解为例,它的处理器是 MockAnnotationProcessor,让我们看下它的 process() 实现:
Mockito#mock()
,至此完成对该字段的 mock② setField() 方法实现如下,比较简单就不介绍了。
获取当前 class 标注了 @Spy 但没有同时标注 @InjectMocks 注解的字段
如果这些字段,同时标注了 @Mock 和 @Captor,则抛出异常
由此可见,@Spy 和 @Mock、@Captor 是不能共存的
尝试直接获取字段的实例
spyInstance()
方法,返回一个 Mock 对象,并赋到字段上spyNewInstance()
,框架帮忙初始化,返回一个 Mock 对象,并赋到字段上对于 spyInstance() 方法,跟 Mockito#spy() 方法比较,几乎一模一样,就不再介绍了。
下面再看 spyNewInstance() 方法,主体的思想,也是想办法构造出一个实例出来,然后走 Mockito Spy 那一套:
看 spyNewInstance() 这一段代码时候,我其实有一些疑惑的:
(1)
Mockito#spy()
也有一个根据 class 构建的 API 方法,这边为什么不能直接用那个呢,而要再写这么一大堆?(2)前文在分析 Mock 那块代码时,看到对 proxy class 的实例化,使用的是
objenesis
框架,为什么这边不也用这个框架来做呢?
这个方法的功能是:
injectMocs()
方法,完成当前 class 的 @InjectMocks 注入重点看 injectMock() 方法:
① 这行代码的逻辑,找到当前类下所有标注了 @InjectMocks 注解的字段,并保存到 mockDependentFields 集合中。
② 这行代码逻辑,把当前类字段中,被 Mockito 管理的属性,都保存到 mocks 集合中。
③ 上面两行代码完成了数据准备,得到了:当前类及其父类中,所有要执行 InjectMock 的字段,所有已经准备好的 Mock 对象。
调用 DefaultInjectionEngine#injectMocksOnFields 真正开始注入:
可以看到采用责任链模式:
在分析最后一个 apply() 方法前,先插队分析下 org.mockito.internal.util.reflection.FieldInitializer
这个类,会在下面的 apply() 方法中用到。
先看下这个类的成员变量:
field
:字段,这里其实就是指 @InjectMocks 标注的那个字段fieldOwner
:指明了这个 field 属于哪个对象instantiator
:当 field 字段默认没有初始化时实例,提供一个初始化策略:再看下 initialize() 方法,它返回一个 FieldInitializationReport 对象,这个对象中最关键的就是 fieldInstance
字段,它其实就是 @InjectMocks 标注的那个字段的实例。
通过调用 acquireFieldInstance()
方法来获取这个实例,而在这个方法中:
现在来分析 apply() 这个方法,这里的 field 就是单个 injectMocks 字段:
1 | // org.mockito.internal.configuration.injection.MockInjection.OngoingMockInjection#apply |
先看 injectionStrategies 负责的责任链:
(1)org.mockito.internal.configuration.injection.ConstructorInjection#process
如上图所示,它构造的 FieldInitializer 对象中的 instantiator 策略是 ParameterizedConstructorInstantiator,在这个策略中:
① 如何选取出一个构造方法呢?规则是:优先取参数数量少的,但至少也得有一个参数。如下图所所示:
② 从所有的 mock 对象中,筛出上一步构造方法中需要的对象。如下图所所示:
(2)org.mockito.internal.configuration.injection.PropertyAndSetterInjection
如果 ConstructorInjection 无法选出的话,就走到 PropertyAndSetterInjection 了,在这个方法中:
① NoArgConstructorInstantiator#instantiate 方法比较简单,单纯的通过无参构造创建实例。
② PropertyAndSetterInjection#injectMockCandidates方法主要功能是:
orderedInstanceFieldsFrom()
确定需要被注入的所有字段
injectMockCandidatesOnFields()
负责注入
没太明白为什么要调用两次 injectMockCandidatesOnFields(),有懂的同学吗?
mockCandidateFilter()
责任链模式实现了字段匹配的功能
这里重点看下如何匹配的那个责任链:
TypeBasedCandidateFilter#TypeBasedCandidateFilter
:匹配类型
NameBasedCandidateFilter#filterCandidate
:匹配参数名
TerminalMockCandidateFilter#filterCandidate
:先尝试走 set 方法赋值,不行的话走反射赋值。
通过上面的内容,为大家讲解了 @Mock、@Spy、@InjectMocks 注解是如何工作的。
]]>:::details 看一眼答案
执行顺序:蓝色 > 绿色 > 红色,想错了的面壁吧。。。
:::
在开始本文之前,咱先根据前面的文章,明确一下已知条件:
如果以上条件你还有疑问,请先别急着往下看,复习下上篇文章,巩固下认知。
下文我将使用这个例子为例,来进行 when 和 then API 的源码分析。
首先执行的方法是 Mockito.eq(1L)
,在 Mockito 的父类 ArgumentMatchers,为我们提供了一系列的 Match 方法,eq()
只是其中一个。
这些方法的底层调用的都是 reportMatcher 这个方法:
在这个方法中,有个我们的老朋友:mockingProgress()
,它在上一节中跟我们有过一面之缘,在本篇文章我们中将会频繁的碰上它。重复下它的作用: 基于 ThreadLocal 实现保存了一个 MockingProgressImpl。
因此 getArgumentMatcherStorage() 操作的就是当前线程 MockingProgressImpl 中的 ArgumentMatcherStorage 对象,并调用它的 reportMatcher() 方法,如下图所示。
reportMatcher() 方法内部,其实是将 Matcher 封装成 LocalizedMatcher 对象,丢进了一个 Stack 中。
LocalizedMatcher 的构造方法中还有一个 Location 的变量,它的作用其实是保存下来当前调用的源码路径。
以我当前的 UT 为例,运行后它长这个样子:
这其实是个小技巧,通过
new Throwable()
获取堆栈信息,然后从中获取有效信息,挺有意思的。
执行完 Mockito.eq(1L)
,下面就开始打桩了,开头说过 OrderClient 这个类已经被 proxy 代理了,因此当 orderClient.queryById(...)
被调用时,会被 DispatcherDefaultingToRealMethod 所拦截。
在这个类中有两个方法,至于具体执行哪一个,我暂时没找到明确的官方证明,但根据我的 Debug 和方法名称推测:
这两个方法唯一的不同,就是当 Stub 不匹配后的容错处理逻辑。对于 interceptSuperCallable(),它可以调用自身的实现,即 callable.call()
。而 interceptAbstract(),由于自身并没有实现,所以只能抛出异常了。
该类中用到了非常多的注解,这些注解其实都是 bytebuddy 所提供的,根据名字相信大家已经能猜出它的涵义了。
注解 | 此处含义 |
---|---|
@This | 当前对象 |
@FieldValue | 获取对象中指定名称的字段 |
@Origin | 调用的方法信息 |
@AllArguments | 调用的方法参数列表 |
@SuperCall | 将原本的调用行为封装成 Future,可以直接执行 |
@StubValue | 没发现啥用,当 mockitoInterceptor 不存在时作为容错结果 |
回过头来,OrderClient 本身是个接口,因此会执行 interceptAbstract 方法,然后被交给了 MockMethodInterceptor 的doIntercept 方法处理,如下图所示:
在该方法中,又交给了 handle 处理,这个 handle 其实就是在我们构建 proxy 时传入的那个 MockHandleImpl 对象。
记不得的同学,复习下这几个方法:
handle 方法的第一个参数是 Invocation,这个对象其实就是把本次调用的信息给整合起来了:
当你对 proxy 类,无论是 when 还是 then 还是 verify 还是普通调用啥的,都会执行 MockHandlerImpl#handle 方法,因此该方法内容很长。所以我会只介绍当前相关的,其他的逻辑会暂时跳过并在下文介绍。
在 MockHandlerImpl#handle 方法中,第一个需要关注的逻辑是如下代码:
1 | InvocationMatcher invocationMatcher = matchersBinder.bindMatchers(mockingProgress().getArgumentMatcherStorage(), invocation); |
这个方法的主要作用是:将之前保存的 ArgumentMatchers 信息都取出来,然后跟 Invocation 绑定在一起,组成 InvocationMatcher。
第二块需要关注逻辑是如下代码:
1 | // prepare invocation for stubbing |
首先 setInvocationForPotentialStubbing
将 Invocation 存入了 registeredInvocations,然后将 InvocationMatcher 放入属性 invocationForStubbing 中。
最后再将整个 invocationContainer 封装成 OngoingStubbingImpl 放入 ThreadLocal 中。
执行完 orderClient.queryById(...)
,下面该执行的就是 Mocktio.when(...)
了,如下图所示:
上面代码的主要功能是:
通过 stubbingStarted()
设置 ThreadLocal 中的 stubbingInProgress。
这里我们可以看到设置 stubbingInProgress 前它会校验 stubbingInProgress 是否存在,因此如果你直接连续两次调用 when 就会触发这里的校验失败。
从 ThreadLocal 中取出并清除之前调用 orderClient.queryById(...)
存入的 OngoingStubbingImpl。
1 | OngoingStubbing<Order> stubbing = Mockito.when(orderClient.queryById(Mockito.eq(1L))); |
这行代码执行完毕后 ,目前已知的条件有:
When 方法执行的相关信息已经被保存到了 OngoingStubbing 中
ThreadLocal 中目前已经设置了 stubbingInProgress,它记录了 Mockito.when(orderClient.queryById(Mockito.eq(1L)))
的源码信息
OngoingStubbingImpl 中的 invocationContainer 中的 registeredInvocations,存储了 orderClient.queryById(...)
的调用信息
下面开始分析 stubbing.thenReturn(...)
源码,你会发现不论你是 thenReturn 还是 thenThrow 还是 thenCallRealMethod,走的其实都是 thenAnswer 的 API。
thenAnswer 方法代码如下,主要有三个功能:
我们重点看 invocationContainer.addAnswer(answer, strictness)
,如下图所示,图中蓝色序号是比较关键的代码:
① 通过调用 removeLast 方法从 invocationContainer 中移除一个 registeredInvocations。来保证了每个 OngoingStubbing 仅能设置一次 answer。
② 调用重载的 addAnswer 方法时, isConsecutive 方法传递为 false,它跟 OngoingStubbing 强绑定,即 OngoingStubbing 调用时永远为 false。
③ 将 ThreadLocal 中的 stubbingInProgress 清除,表示之前调用 when 的数据已经不需要了。
④ 设置结果,这里封装了一个 StubbedInvocationMatcher 对象,它其实就代表了一个桩(Stub)。可以看到它的构造方法中已经把需要的数据都准备好了:
invocation.getInvocation()
:对应的方法信息,即 orderClient.queryById(...)
invocation.getMatchers()
:对应的方法参数匹配信息,即 Mockito.eq(1L)
answer
:对应的方法返回值现在我们再把刚刚带过的 ConsecutiveStubbing 给补上,通过类图,我们发现它跟 OngoingStubbing 同级。
二者的区别是使用 ConsecutiveStubbing 时,可以对一个 Stub,添加多个 answer,它的 thenAnswer 方法参数的 isConsecutive 就为 true 了,这也是为什么 StubbedInvocationMatcher 底层的 answers 用了 Queue 来存储的原因。
因此,下面的写法是错误的:
1 | OngoingStubbing<Order> stubbing = Mockito.when(orderClient.queryById(Mockito.eq(1L))); |
因为 OngoingStubbing 的 thenAnswer 方法会调用 removeLast(),里面会清除 registeredInvocations,导致第二次执行时无法通过校验:
正确的写法应该是:
1 | // 返回子类 OngoingStubbingImpl |
下面开始真正测试了,当执行如下代码时,期望能够从 Stub 中取出数据:
1 | final Order order1 = orderClient.queryById(1L); |
源码还得回到 MockHandlerImpl#handle
来,没办法谁让所有方法都被它给代理了呢。
你会发现现在走的流程跟 orderClient.queryById(Mockito.eq(1L))
一模一样,唯一不同的是 invocationContainer.findAnswerFor(invocation)
方法返回的不再是 NULL。
findAnswerFor() 的功能就是根据 invocation 从所有的 Stub 中寻找是否存在匹配的 Stub,如果找到匹配的,返回对应的结果。
answer
方法中会判断 answers 的元素数量,如果有多个(ConsecutiveStubbing 情况),返回头部元素并从 Queue 中移除;如果只有一个(OngoingStubbingImpl 或仅剩一个的 ConsecutiveStubbing 情况),返回这个并且不从 Queue 中移除。
下面让我们重点看下 findAnswerFor 是如何匹配 Stub 的,如下所示:
可以看到匹配的条件包括:
这里特别要注意的是,第一个条件比较的是对象,而不是类,因此下面的 UT 是无法通过的:
1 | final OrderClient orderClient1 = PowerMockito.mock(OrderClient.class); |
插个题外话,上面我说了一句:
你会发现现在走的流程跟
orderClient.queryById(Mockito.eq(1L))
一模一样,唯一不同的是invocationContainer.findAnswerFor(invocation)
方法返回的不再是 NULL。
那么你觉得下面的代码运行结果是啥?
1 | final OrderClient orderClient = PowerMockito.mock(OrderClient.class); |
一开始我认为这走的逻辑都是一样的啊,上面的代码执行肯定是相等的,其实运行结果是不相等。原因就在于 Mockito.eq(1L)
并不是真正的参数,而是一个 matcher,如下图所示。
所以如果非要让上面的代码运行相等,只要把 1L 改成 0L 就行了,哈哈哈(PS:这是玩笑话,别学…)。
最后补充下 Verify,这里我就以最简单的 Mocito.times 为例了。UT 如下,验证是否执行了一次。
when…then… 处的代码就直接忽略了,先从 orderClient.queryById(1L)
方法开始看。
我们直接跳到 MockHandlerImpl#handle,让我们再回顾下 invocationContainer.setInvocationForPotentialStubbing
这段代码,它会将本次的 invocation 行为存储进 invocationContainer 的 registeredInvocations 中。然后通过 invocationContainer.findAnswerFor
尝试获取一个匹配的 Stub,然后返回结果。
只需要重点注意,本次的 invocation 行为已经被保存起来了(是不是有点流量录制的味道了?)。
接下来让我们 Mockito#verify 方法,除掉校验和非主干逻辑,真正核心的就是 mockingProgress.verificationStarted
一行代码:将当前 mock 对象和要 verify 的模式(actualMode)封装成 MockAwareVerificationMode 对象,然后存入 ThreadLocal 中。
设置完毕后,继续执行后续代码:queryById(1L)
,继续把视野移到 MockHandlerImpl#handle,之前一直被我们无视的代码块终于走到了。因为上一步将 MockAwareVerificationMode 存入到 ThreadLocal 中,因此当执行 mockingProgress().pullVerificationMode()
时就能取出来了。
首先判断 MockAwareVerificationMode 需要验证的对象和当前对象是否相同(注意这里用等号判断,比较的是地址)。然后构造 VerificationDataImpl,比较简单,如下所示:
主要的验证就是 verificationMode.verify(data)
这行代码,如果通过后,返回 null 即可;如果未通过,则会抛出异常。
下面我就以 Times 为例,看看它的 verify 方法实现:
data.getAllInvocations()
:获取直接所有的 Invocation 记录
data.getTarget()
:获取想要验证的 Invocation
如果 wantedCount > 0,表明需要验证次数,调用 checkMissingInvocation() 方法,防止 getAllInvocations() 中一个匹配的都没有,存在 null 的情况
调用 checkNumberOfInvocations() 方法,比对次数,这个太简单就不介绍了。
总结下 org.mockito.internal.stubbing.InvocationContainerImpl#registeredInvocations:
(1)什么时候会有值?
基本每次调用 proxy 代理类的方法时,都会将当前的 Invocation 存入。
(2)什么时候消费它?
每调用一次 when(ConsecutiveStubbing 除外),registeredInvocations 就会 removeLast。
调用 verify 时,会拿所有的 registeredInvocations,进行比较,但不会移除。
不得不说这个方法太重要了,再总结下它的代码模块吧。
]]>上来先丢一张时序图,后面在看源码的时候可以结合这张图,更容易理解。
为了方便调试,我没有使用注解的方式
无论你是使用 Mockito.mock()
还是 PowerMockito.mock()
,其都会调用 MockitoCore.mock 方法(这里需要注意,MockitoCore 对象是 Static 属性,因此全局仅有一个)。
进入 MockitoCore#mock 方法后,它做了主要三件事,如下图所示:
createMock 方法中最核心的就是前两行代码。如下图所示:
这里首先创建了 MockHandler 对象,它本身是一个接口。通过追查 MockHandlerFactory#createMockHandler 方法可以得知,这里使用了委派模式,真正的核心逻辑是由 MockHandlerImpl 处理的。
接着来看第二行的 MockMaker,它也是一个接口,通过 Plugins.getMockMaker()
方式获取了一个静态实例。通过跟踪代码,确定此处的实例是 ByteBuddyMockMaker。
限于主题关系,这里没有办法展开介绍 ByteBuddy 框架,你可以认为该框架可以帮助我们在程序运行期间动态的创建出一个类并加载到 JVM 中,后面有时间我再单独介绍该框架。
进入该类后发现依然用了委派,实际处理的是 SubclassByteBuddyMockMaker,因此我们直接看该类的 createMock 方法即可。
在该方法中,主要做了三件事:
通过这几行代码,我们能得到一些信息:
咱先看 createMockType() 方法,如下图所示,又是委派,TypeCachingBytecodeGenerator -> SubclassBytecodeGenerator。
先看 TypeCachingBytecodeGenerator,由于该类涉及到 bytebuddy,就不带大家继续往里跟了,直接说明下该类的作用:
保存一个全局的 TypeCache 缓存,如果当前正在 mock 的类,曾经 mock 过了,那么直接会从缓存中返回,否则执行创建并插入缓存的回调方法,这个回调方法就是 SubclassBytecodeGenerator 的实现。
写一个 UT 来验证这一点,如下图所示,可以看到,对于同一个类执行多次 mock,返回的是同一个类。
下面来看 org.mockito.internal.creation.bytebuddy.SubclassBytecodeGenerator#mockClass,这个方法实现了如何创建 proxy 类。该方法代码很多,但其中最核心的一段代码,我给大家标注出来了,如下所示:
以上代码展示了如何通过 bytebuddy 创建出一个 proxy 类。
① subClass 和 implement 方法,申明了该 proxy 类需要继承和实被 mock 类的父类和接口。只有这样,才能强转回去。
② name 方法指定了该 proxy 类的类名,生成规则如下:
③ mehod 方法参数需要一个 ElementMatcher,可以理解为是一个匹配器,追查源码后发现值为 any(),即匹配所有方法。紧跟着的 dispatcher 方法,表示对于上面匹配的方法,指定一个拦截器,被匹配的方法都会被该拦截器拦截,这里的实现类是 DispatcherDefaultingToRealMethod。
④ defineField 方法表示为该 proxy 类增加一个字段,该行代码等价于:
1 | private MockMethodInterceptor mockitoInterceptor; |
implement 方法会该 proxy 类添加了 MockAccess 接口,可以看到该接口完全就是给 mockitoInterceptor 量身定制的。
至此完成了 proxy 类的构建,下面让我们回到 SubclassByteBuddyMockMaker#createMock,该方法的第二件事是实例化。由于此处不是很重要,就不展示跟代码的细节了,直接告诉大家它的实现类是 ObjenesisInstantiator,如下图所示。这里用到的了 objenesis 框架,该框架可以帮助我们简单的创建类的实例。
完成实例化后,跟着代码一路回退到 MockitoCore#mock,我们来看这行代码:
1 | mockingProgress().mockingStarted(mock, creationSettings); |
mockingProgress()
方法返回一个 MockingProgress 实例,即 MockingProgressImpl。需要注意的是,它被保存在了 TreadLocal 中,也就是说每个线程的 MockingProgress 是相同的。
mockingStarted
方法内部,看着像是监听器模式,这个跟咱们主流程不搭嘎,就不研究了。
既然都看到了这个类,就看看 MockingProgress 这个接口的方法把,如下图所示。
在 Mockito 类中,spy 跟 mock 的区别就是对于 MockSettings 参数,spy 多了对 spiedInstance 和 defaultAnswer 的配置。
关于 spiedInstance 的作用,参见 MockUtil#createMock 的方法,在完成 mock 后,会判断该属性。简单来说作用就是把 spiedInstance 的属性拷贝到 mock 出来的对象上。
因此,如下代码,得到的两个 Order,属性是一致的。
关于 defaultAnswer,可以看到它指定了 CALLS_REAL_METHODS,而对于 mock,这个值则是默认的 RETURNS_DEFAULTS。
猜测这个参数会在实际执行时候产生差异,这里先按下不表,等到下一节再解释。
]]>作为一个合格的开发工程师,写好 UT(Unit Test) 是必备的技能,目前市面上 UT 工具很多。我选用了使用最为广泛的 Mockito + Powermock 的组合,来分析它的源码,希望能为大家带来收获。
请注意,本系列不会为大家介绍基础用法。
系列开始之前,首先约定下版本:
1 | <dependency> |
如上图所示,我使用了 powermock 2.0.9 版本,整合了 junit + mockito 框架。
在我最初学习 Mockito 时候,对 Mock、InjectMocks、Spy 这三位老伙计的含义有些歧义,特在此处中先说明白。
在对某个类使用 Mock 后。那么该类的所有成员变量都会置为默认值,所有的方法实现也会置空,便于后续自定义实现。如下图所示,对于类 Order,其成员变量被设置成默认值,其方法 print() 实现也被清空,方法返回了默认值。
而 Spy 则跟 Mock 正好相反,它有点类似于 new,其构造出的类,都会获取其真实的成员变量和方法。
如果有个类,想将其完全 mock 掉,仅对部分我用到的方法进行实现,那么用 mock 即可;如果有个类,仅想 mock 其中的一小部分,其他部分执行真实逻辑,那么用 spy 即可。
对于 InjectMocks,它会创建出一个类的实例,在这一点上与 Spy 类似。另外它会搜寻所有的 mock 和 spy,如果该类内部有这些对象的引用,将会把它们注入进来,有点像 IOC 容器。
如上图所示,当我对 OrderService 使用 InjectMocks 后,其被 mock 的成员变量 OrderClient 被自动注入其中,并且调用 OrderService 的方法时,走的是真实逻辑。
不知道大家在初次接触 Mock 时候时,有没有好奇过,为什么它可以通过寥寥几行代码,就实现了这么牛逼的功能,这也是我去研究它源码的原因。
简单来说,当你在 mock 时候,它会通过 proxy 的方式创建一个代理类。后续你在进行 whenThen 打桩(stub)的时候,它会将这些 stub 都保存起来。等到你真正用的时候,优先判断 stub 是否存在预期结果,如果存在就返回该值,不存在就根据情况返回默认值或去调用真实方法。
是不是听起来还挺简单,下一篇文章将正式开启源码分析的部分了。
]]>Protobuf 默认并没有提供跟 JSON 的互相转换方法,虽然 Protobuf 对象自身有 toString()
方法,但是并非是 JSON 格式,而是形如:
1 | age: 57 |
本篇文章中,我将使用 protobuf-java-util
实现 Protobuf 对象的 JSON 序列化,使用 fastjson
实现反序列化,在文章最后,我编写了一个 fastjson 的转换器,来帮助大家更加优雅的实现 Protobuf 的序列化与反序列化。
首先需要引入 protobuf-java-util
依赖,其版本号跟 protobuf-java
一致即可,例如:
1 | <dependency> |
然后新建一个工具类 ProtobufUtils
,在其中以静态的方式初始化 JsonFormat.Printer
和 JsonFormat.Parser
,前者用于序列化,后者用于反序列化。
1 | public class ProtobufUtils { |
为了便于验证,这里先定义好 Proto,后续用于测试:
1 | // enums.proto |
1 | // user.proto |
提供一个随机创建 Protobuf 的方法(使用到了 commons-lang3
包):
1 | protected MessageProto.User randomUser() { |
1 | public static String toJson(Message message) { |
1 | public static <T extends Message> T toBean(String json, Class<T> clazz) { |
1 |
|
1 | public static String toJson(List<? extends MessageOrBuilder> messageList) { |
1 | public static <T extends Message> List<T> toBeanList(String json, Class<T> clazz) { |
1 |
|
这里我只实现了 Key 是普通类型,Value 是 Message 类型。如果有其他需求可以二次开发。
1 | public static String toJson(Map<?, ? extends Message> messageMap) { |
1 | public static <K, V extends Message> Map<K, V> toBeanMap(String json, Class<K> keyClazz, Class<V> valueClazz) { |
1 |
|
最后给大家提供一个 fastjson 的转换器,免去手动序列化反序列化的烦恼。
1 | public class ProtobufCodec implements ObjectSerializer, ObjectDeserializer { |
首先写一个对象来测试下:
1 |
|
最后附上测试用例:
1 |
|
大家在日常工作中,一定使用过 Spring 的 @Scheduled
注解吧,通过该注解可以非常方便的帮助我们实现任务的定时执行。
但是该注解是不支持运行时动态修改执行间隔的,不知道你在业务中有没有这些需求和痛点:
这些都可以通过 Spring 的 SchedulingConfigurer
注解来实现。
这个注解其实大家并不陌生,如果有使用过 @Scheduled 的话,因为 @Scheduled 默认是单线程执行的,因此如果存在多个任务同时触发,可能触发阻塞。使用 SchedulingConfigurer 可以配置用于执行 @Scheduled 的线程池,来避免这个问题。
1 |
|
但其实这个接口,还可以实现动态定时任务的功能,下面来演示如何实现。
后续定义的类开头的
DS
是Dynamic Schedule
的缩写。
使用到的依赖,除了 Spring 外,还包括:
1 | <dependency> |
首先需要开启 @EnableScheduling
注解,直接在启动类添加即可:
1 |
|
定义一个任务信息的接口,后续所有用于动态调整的任务信息对象,都需要实现该接口。
id
:该任务信息的唯一 ID,用于唯一标识一个任务cron
:该任务执行的 cron 表达式。isValid
:任务开关isChange
:用于标识任务参数是否发生了改变1 | public interface IDSTaskInfo { |
顾名思义,是存放 IDSTaskInfo 的容器。
具有以下成员变量:
scheduleMap
:用于暂存 IDSTaskInfo 和实际任务 ScheduledTask 的映射关系。其中:taskRegistrar
:Spring 的任务注册管理器,用于注册任务到 Spring 容器中name
:调用方提供的类名具有以下成员方法:
void checkTask(final T taskInfo, final TriggerTask triggerTask)
:检查 IDSTaskInfo,判断是否需要注册/取消任务。具体的逻辑包括:Semaphore getSemaphore()
:获取信号量属性。1 | import lombok.extern.slf4j.Slf4j; |
下面定义实际的动态线程池处理方法,这里采用抽象类实现,将共用逻辑封装起来,方便扩展。
具有以下抽象方法:
List<T> listTaskInfo()
:获取所有的任务信息。void doProcess(T taskInfo)
:实现实际执行任务的业务逻辑。具有以下公共方法:
void configureTasks(ScheduledTaskRegistrar taskRegistrar)
:创建 DSContainer 对象,并创建一个单线程的任务定时执行,调用 scheduleTask() 方法处理实际逻辑。void scheduleTask()
:首先加载所有任务信息,然后基于 cron 表达式生成 TriggerTask 对象,调用 checkTask() 方法确认是否需要注册/取消任务。当达到执行时间时,调用 execute() 方法,执行任务逻辑。void execute(final T taskInfo)
:获取信号量,成功后执行任务逻辑。1 | import lombok.extern.slf4j.Slf4j; |
至此就完成了动态任务的框架搭建,下面让我们来快速测试下。为了尽量减少其他技术带来的复杂度,本次测试不涉及数据库和真实的定时任务,完全采用模拟实现。
为了模拟一个定时任务,我定义了一个 foo()
方法,其中只输出一句话。后续我将通过定时调用该方法,来模拟定时任务。
1 | import lombok.extern.slf4j.Slf4j; |
首先定义 IDSTaskInfo,我这里想通过反射来实现调用 foo()
方法,因此 reference
表示的是要调用方法的全路径。另外我实现了 isChange()
方法,只要 cron、isValid、reference 发生了变动,就认为该任务的配置发生了改变。
1 | import com.github.jitwxs.sample.ds.config.IDSTaskInfo; |
有几个需要关注的:
(1)listTaskInfo()
返回值我使用了 volatile 变量,便于我修改它,模拟任务信息数据的改变。
(2)doProcess()
方法中,读取到 reference 后,使用反射进行调用,模拟定时任务的执行。
(3)额外实现了 ApplicationListener
接口,当服务启动后,每隔一段时间修改下任务信息,模拟业务中调整配置。
1 | import com.github.jitwxs.sample.ds.config.AbstractDSHandler; |
整个应用包结构如下:
运行程序后,在控制台可以观测到如下输出:
以上完成了动态定时任务的介绍,你能够根据本篇文章,实现以下需求吗:
在介绍双亲委派机制之前,必须先要了解类加载器 ClassLoader
,当 .java
文件经过编译生成 .class
文件后,需要通过 ClassLoader 将其加载入 JVM 中。
Java 中包括以下四类 ClassLoader:
java,lang.*
)等】jre/lib/ext
目录下的扩展类库】通过 java.lang.Class#getClassLoader
方法就能够知道加载某个类的具体 ClassLoader,如下面代码所示:
1 | import sun.net.spi.nameservice.dns.DNSNameService; |
输出结果如下:
1 | null |
Object.class
是 java.lang
类,它的 ClassLoader 是 Bootstrap ClassLoader,但是由于 Bootstrap ClassLoader 采用 C++ 实现,因此其的输出结果为 null。DNSNameService.class
是 JRE 扩展包类,它的 ClassLoader 是 Extension ClassLoader。Foo.class
是我自定义的类,它的 ClassLoader 是 Application ClassLoader。上一节提到 Object.class
是 java.lang
包提供的类。如果我自己也定义一个 java.lang.Object
类,能够编译通过吗?
1 | package java.lang; |
运行如上代码后会报错,原因就是因为 Java 的双亲委托机制。
1 | 错误: 在类 java.lang.Object 中找不到 main 方法, 请将 main 方法定义为: |
当 JVM 接收到需要加载类的请求时
(1)首先自下向上判断该类是否已经加载:
首先判断 Custom ClassLoader 是否已经加载该类,如果没有加载,委派给自己的 parent classLoader --> Extension ClassLoader。
判断 Application ClassLoader 是否已经加载该类,如果没有加载,委派给自己的 parent classLoader --> Extension ClassLoader。
判断 Extension ClassLoader 是否已经加载该类,如果没有加载,委派给自己的 parent classLoader --> Bootstrap ClassLoader。
判断 Bootstrap ClassLoader 是否已经加载该类。
(2)如果所有 ClassLoader 都未加载,则自上向下加载该类:
下面两张流程图形象的描述了这种关系:
这也就解释了为什么 1.2 节的代码运行会报错的原因。当运行 main 方法后,加载 java.lang.Object.class
的不是 Application ClassLoader 而是 Bootstrap ClassLoader,即实际被加载的是源码包的 Object.class,在那个类中是不存在 main 方法的。
(1)为什么要自下向上判断类是否已经加载?
确保类只被加载一次,如果 parent ClassLoader 已经加载,那么 child ClassLoader 就无需加载。
(2)为什么要自上向下加载类?
确保 Java 核心类库不被 Application ClassLoader 或 Custom ClassLoader 所加载,保证安全。
1 | public class ClassLoaderDemo { |
ClassLoader.getSystemClassLoader()
获取默认委托的 ClassLoader,即最底层的。getParent()
获取上层 ClassLoader,当为 null 时表示当前已经是 Bootstrap ClassLoader。运行结果如下:
1 | ClassLoader: sun.misc.Launcher$AppClassLoader@18b4aac2 |
经过《如果让你来设计网络》这篇文章中的一番折腾,只要你知道另一位伙伴 B 的 IP 地址,且你们之间的网络是通的,无论多远,你都可以将一个数据包发送给你的伙伴 B
这就是物理层、数据链路层、网络层这三层所做的事情。
站在第四层的你,就可以不要脸地利用下三层所做的铺垫,随心所欲地发送数据,而不必担心找不到对方了。
虽然你此时还什么都没干,但你还是给自己这一层起了个响亮的名字,叫做传输层。
你本以为自己所在的第四层万事大吉,啥事没有,但很快问题就接踵而至。
前三层协议只能把数据包从一个主机搬到另外一台主机,但是,到了目的地以后,数据包具体交给哪个程序(进程)呢?
所以,你需要把通信的进程区分开来,于是就给每个进程分配一个数字编号,你给它起了一个响亮的名字:端口号。
然后你在要发送的数据包上,增加了传输层的头部,源端口号与目标端口号。
OK,这样你将原本主机到主机的通信,升级为了进程和进程之间的通信。
你没有意识到,你不知不觉实现了 UDP 协议!
(当然 UDP 协议中不光有源端口和目标端口,还有数据包长度和校验值,我们暂且略过)
就这样,你用 UDP 协议无忧无虑地同 B 进行着通信,一直没发生什么问题。
但很快,你发现事情变得非常复杂…
由于网络的不可靠,数据包可能在半路丢失,而 A 和 B 却无法察觉。
对于丢包问题,只要解决两个事就好了。
第一个,A 怎么知道包丢了?
答案:让 B 告诉 A
第二个,丢了的包怎么办?
答案:重传
于是你设计了如下方案,A 每发一个包,都必须收到来自 B 的确认(ACK),再发下一个,否则在一定时间内没有收到确认,就重传这个包。
你管它叫停止等待协议。只要按照这个协议来,虽然 A 无法保证 B 一定能收到包,但 A 能够确认 B 是否收到了包,收不到就重试,尽最大努力让这个通信过程变得可靠,于是你们现在的通信过程又有了一个新的特征,可靠交付。
停止等待虽然能解决问题,但是效率太低了,A 原本可以在发完第一个数据包之后立刻开始发第二个数据包,但由于停止等待协议,A 必须等数据包到达了 B ,且 B 的 ACK 包又回到了 A,才可以继续发第二个数据包,这效率慢得可不是一点两点。
于是你对这个过程进行了改进,采用流水线的方式,不再傻傻地等。
但是网路是复杂的、不可靠的。
有的时候 A 发出去的数据包,分别走了不同的路由到达 B,可能无法保证和发送数据包时一样的顺序。
在流水线中有多个数据包和ACK包在乱序流动,他们之间对应关系就乱掉了。
难道还回到停止等待协议?A 每收到一个包的确认(ACK)再发下一个包,那就根本不存在顺序问题。应该有更好的办法!
A 在发送的数据包中增加一个序号(seq),同时 B 要在 ACK 包上增加一个确认号(ack),这样不但解决了停止等待协议的效率问题,也通过这样标序号的方式解决了顺序问题。
而 B 这个确认号意味深长:比如 B 发了一个确认号为 ack = 3,它不仅仅表示 A 发送的序号为 2 的包收到了,还表示 2 之前的数据包都收到了。这种方式叫累计确认或累计应答。
注意,实际上 ack 的号是收到的最后一个数据包的序号 seq + 1,也就是告诉对方下一个应该发的序号是多少。但图中为了便于理解,ack 就表示收到的那个序号,不必纠结。
有的时候,A 发送数据包的速度太快,而 B 的接收能力不够,但 B 却没有告知 A 这个情况。
怎么解决呢?
很简单,B 告诉 A 自己的接收能力,A 根据 B 的接收能力,相应控制自己的发送速率,就好了。
B 怎么告诉 A 呢?B 跟 A 说"我很强"这三个字么?那肯定不行,得有一个严谨的规范。
于是 B 决定,每次发送数据包给 A 时,顺带传过来一个值,叫窗口大小(win),这个值就表示 B 的接收能力。同理,每次 A 给 B 发包时也带上自己的窗口大小,表示 A 的接收能力。
B 告诉了 A 自己的窗口大小值,A 怎么利用它去做 A 这边发包的流量控制呢?
很简单,假如 B 给 A 传过来的窗口大小 win = 5,那 A 根据这个值,把自己要发送的数据分成这么几类。
图片过于清晰,就不再文字解释了。
当 A 不断发送数据包时,已发送的最后一个序号就往右移动,直到碰到了窗口的上边界,此时 A 就无法继续发包,达到了流量控制。
但是当 A 不断发包的同时,A 也会收到来自 B 的确认包,此时整个窗口会往右移动,因此上边界也往右移动,A 就能发更多的数据包了。
以上都是在窗口大小不变的情况下,而 B 在发给 A 的 ACK 包中,每一个都可以重新设置一个新的窗口大小,如果 A 收到了一个新的窗口大小值,A 会随之调整。
如果 A 收到了比原窗口值更大的窗口大小,比如 win = 6,则 A 会直接将窗口上边界向右移动 1 个单位。
如果 A 收到了比原窗口值小的窗口大小,比如 win = 4,则 A 暂时不会改变窗口大小,更不会将窗口上边界向左移动,而是等着 ACK 的到来,不断将左边界向右移动,直到窗口大小值收缩到新大小为止。
OK,终于将流量控制问题解决得差不多了,你看着上面一个个小动图,给这个窗口起了一个更生动的名字,滑动窗口。
但有的时候,不是 B 的接受能力不够,而是网络不太好,造成了网络拥塞。
拥塞控制与流量控制有些像,但流量控制是受 B 的接收能力影响,而拥塞控制是受网络环境的影响。
拥塞控制的解决办法依然是通过设置一定的窗口大小,只不过,流量控制的窗口大小是 B 直接告诉 A 的,而拥塞控制的窗口大小按理说就应该是网络环境主动告诉 A。
但网络环境怎么可能主动告诉 A 呢?只能 A 单方面通过试探,不断感知网络环境的好坏,进而确定自己的拥塞窗口的大小。
拥塞窗口大小的计算有很多复杂的算法,就不在本文中展开了,假如拥塞窗口的大小为 cwnd,上一部分流量控制的滑动窗口的大小为 rwnd,那么窗口的右边界受这两个值共同的影响,需要取它俩的最小值。
窗口大小 = min(cwnd, rwnd)
含义很容易理解,当 B 的接受能力比较差时,即使网络非常通畅,A 也需要根据 B 的接收能力限制自己的发送窗口。当网络环境比较差时,即使 B 有很强的接收能力,A 也要根据网络的拥塞情况来限制自己的发送窗口。正所谓受其短板的影响嘛~
有的时候,B 主机的相应进程还没有准备好或是挂掉了,A 就开始发送数据包,导致了浪费。
这个问题在于,A 在跟 B 通信之前,没有事先确认 B 是否已经准备好,就开始发了一连串的信息。就好比你和另一个人打电话,你还没有"喂"一下确认对方有没有在听,你就巴拉巴拉说了一堆。
这个问题该怎么解决呢?
地球人都知道,三次握手嘛!
A:我准备好了(SYN)
B:我知道了(ACK),我也准备好了(SYN)
A:我知道了(ACK)
A 与 B 各自在内存中维护着自己的状态变量,三次握手之后,双方的状态都变成了连接已建立(ESTABLISHED)。
虽然就只是发了三次数据包,并且在各自的内存中维护了状态变量,但这么说总觉得太 low,你看这个过程相当于双方建立连接的过程,于是你灵机一动,就叫它面向连接吧。
注意:这个连接是虚拟的,是由 A 和 B 这两个终端共同维护的,在网络中的设备根本就不知道连接这回事儿!
但凡事有始就有终,有了建立连接的过程,就要考虑释放连接的过程,又是地球人都知道,四次挥手嘛!
A:再见,我要关闭了(FIN)
B:我知道了(ACK)
给 B 一段时间把自己的事情处理完…
B:再见,我要关闭了(FIN)
A:我知道了(ACK)
以上讲述的,就是 TCP 协议的核心思想,上面过程中需要传输的信息,就体现在 TCP 协议的头部,这里放上最常见的 TCP 协议头解读的图。
不知道你现在再看下面这句话,是否能理解:
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议
面向连接、可靠,这两个词通过上面的讲述很容易理解,那什么叫做基于字节流呢?
很简单,TCP 在建立连接时,需要告诉对方 MSS(最大报文段大小)。
也就是说,如果要发送的数据很大,在 TCP 层是需要按照 MSS 来切割成一个个的 TCP 报文段 的。
切割的时候我才不管你原来的数据表示什么意思,需要在哪里断句啥的,我就把它当成一串毫无意义的字节,在我想要切割的地方咔嚓就来一刀,标上序号,只要接收方再根据这个序号拼成最终想要的完整数据就行了。
在我 TCP 传输这里,我就把它当做一个个的字节,也就是基于字节流的含义了。
最后留给大家一个作业,模拟 A 与 B 建立一个 TCP 连接。
第一题:A 给 B 发送 “aaa” ,然后 B 给 A 回复一个简单的字符串 “success”,并将此过程抓包。
第二题:A 给 B 发送 “aaaaaa … a” 超过最大报文段大小,然后 B 给 A 回复一个简单的字符串 “success”,并将此过程抓包。
下面是我抓的包(第二题)
三次握手阶段
A -> B [SYN] Seq=0 Win=64240 Len=0
MSS=1460 WS=256
B - >A [SYN, ACK] Seq=0 Ack=1 Win=29200 Len=0
MSS=1424 WS=512
A -> B [ACK] Seq=1 Ack=1 Win=132352 Len=0
数据发送阶段
A -> B [ACK] Seq=1 Ack=1 Win=132352 Len=1424
A -> B [ACK] Seq=1425 Ack=1 Win=132352 Len=1424
A -> B [PSH, ACK] Seq=2849 Ack=1 Win=132352 Len=1247
B -> A [ACK] Seq=1 Ack=1425 Win=32256 Len=0
B -> A [ACK] Seq=1 Ack=2849 Win=35328 Len=0
B -> A [ACK] Seq=1 Ack=4096 Win=37888 Len=0
B -> A [PSH, ACK] Seq=1 Ack=4096 Win=37888 Len=7
四次挥手阶段
B -> A [FIN, ACK] Seq=8 Ack=4096 Win=37888 Len=0
A -> B [ACK] Seq=4096 Ack=9 Win=132352 Len=0
A -> B [FIN, ACK] Seq=4096 Ack=9 Win=132352 Len=0
]]>闪客:没问题,这个我擅长,咱们从一个最简单的情况开始,假设有一段代码,你希望异步执行它,是不是要写出这样的代码?
1 | new Thread(r).start(); |
小宇:嗯嗯,最简单的写法似乎就是这样呢。
闪客:这种写法当然可以完成功能,可是你这样写,老王这样写,老张也这样写,程序中到处都是这样创建线程的方法,能不能写一个统一的工具类让大家调用呢?
小宇:可以的,感觉有一个统一的工具类,更优雅一些。
闪客:那如果让你来设计这个工具类,你会怎么写呢?我先给你定一个接口,你来实现。
1 | public interface Executor { |
小宇:emmm,我可能先定义几个成员变量,比如核心线程数、最大线程数 …反正就是那些乱七八糟的参数。
闪客:STOP!小宇呀,你现在深受面试手册的毒害,你先把这些全部的概念忘掉,就说让你写一个最简单的工具类,第一反应,你会怎么写?
小宇:那我可能会这样
1 | // 新线程:直接创建一个新线程运行 |
闪客:嗯嗯很好,你的思路非常棒。
小宇:啊,我这个会不会太 low 了呀,我还以为你会骂我呢。
怎么会, Doug Lea 大神在 JDK 源码注释中给出的就是这样的例子,这是最根本的功能。你在这个基础上,尝试着优化一下?
小宇:还能怎么优化呢?这不已经用一个工具类实现了异步执行了嘛!
闪客:我问你一个问题,假如有 10000 个人都调用这个工具类提交任务,那就会创建 10000 个线程来执行,这肯定不合适吧!能不能控制一下线程的数量呢?
小宇:这不难,我可以把这个任务 r 丢到一个 tasks 队列中,然后只启动一个线程,就叫它 Worker 线程吧,不断从 tasks 队列中取任务,执行任务。这样无论调用者调用多少次,永远就只有一个 Worker 线程在运行,像这样。
闪客:太棒了,这个设计有了三个重大的意义:
控制了线程数量。
队列不但起到了缓冲的作用,还将任务的提交与执行解耦了。
最重要的一点是,解决了每次重复创建和销毁线程带来的系统开销。
小宇:哇真的么,这么小的改动有这么多意义呀。
闪客:那当然,不过只有一个后台的工作线程 Worker 会不会少了点?还有如果这个 tasks 队列满了怎么办呢?
小宇:哦,的确,只有一个线程在某些场景下是很吃力的,那我把 Worker 线程的数量增加?
闪客:没错,Worker 线程的数量要增加,但是具体数量要让使用者决定,调用时传入,就叫核心线程数 corePoolSize 吧。
小宇:好的,那我这样设计。
初始化线程池时,直接启动 corePoolSize 个工作线程 Worker 先跑着。
这些 Worker 就是死循环从队列里取任务然后执行。
execute 方法仍然是直接把任务放到队列,但队列满了之后直接抛弃
闪客:太完美了,奖励你一块费列罗吧。
小宇:哈哈谢谢,那我先吃一会儿哈。
闪客:好,你边吃我边说。现在我们已经实现了一个至少不那么丑陋的线程池了,但还有几处小瑕疵,比如初始化的时候,就创建了一堆 Worker 线程在那空跑着,假如此时并没有异步任务提交过来执行,这就有点浪费了。
小宇:哦好像是诶!
闪客:还有,你这队列一满,就直接把新任务丢弃了,这样有些粗暴,能不能让调用者自己决定该怎么处理呢?
小宇:哎呀,想不到我这么温柔的妹纸居然写出了这么粗暴的代码。
闪客:额,你先把费列罗咽下去吧。
小宇:我吃完了,现在脑子有点不够用了,得先消化消化食物,要不你帮我分析分析吧。
闪客:好的,现在我们做出如下改进。
1. 按需创建Worker:刚初始化线程池时,不再立刻创建 corePoolSize 个工作线程,而是等待调用者不断提交任务的过程中,逐渐把工作线程 Worker 创建出来,等数量达到 corePoolSize 时就停止,把任务直接丢到队列里。那就必然要用一个属性记录已经创建出来的工作线程数量,就叫 workCount 吧。
2. 加拒绝策略:实现上就是增加一个入参,类型是一个接口 RejectedExecutionHandler,由调用者决定实现类,以便在任务提交失败后执行 rejectedExecution 方法。
3. 增加线程工厂:实现上就是增加一个入参,类型是一个接口 ThreadFactory,增加工作线程时不再直接 new 线程,而是调用这个由调用者传入的 ThreadFactory 实现类的 newThread 方法。
就像下面这样。
小宇:哇,还是你厉害,这一版应该很完美了吧?
闪客:不不不,离完美还差得很远,接下来的改进,由你来想吧,我这里可以给你一个提示:弹性思维
小宇:弹性思维?哈哈闪客你这术语说的真是越来越不像人话了
闪客:咳咳
小宇:哦,我是说你肯定是指我这个代码写的没有弹性,对吧?可是弹性是指什么呢?
闪客:简单说,在这个场景里,弹性就是在任务提交比较频繁,和任务提交非常不频繁这两种情况下,你这个代码是否有问题?
小宇:emmm 让我想想,我这个线程池,当提交任务的量突增时,工作线程和队列都被占满了,就只能走拒绝策略,其实就是被丢弃掉
闪客:是的
小宇:这样的确是太硬了,诶不过我想了下,调用方可以通过设置很大的核心线程数 corePoolSize 来解决这个问题呀。
闪客:的确是可以,但一般场景下 QPS 高峰期都很短,而为了这个很短暂的高峰,设置很大的核心线程数,简直太浪费资源了,你看上面的图不觉得眼晕么?
小宇:是呀,那怎么办呢,太大了也不行,太小了也不行。
闪客:我们可以发明一个新的属性,叫最大线程数 maximumPoolSize。当核心线程数和队列都满了时,新提交的任务仍然可以通过创建新的工作线程(叫它非核心线程),直到工作线程数达到 maximumPoolSize 为止,这样就可以缓解一时的高峰期了,而用户也不用设置过大的核心线程数。
小宇:哦好像有点感觉了,可是具体怎么操作呢?
闪客:想象力不行呀小宇,那你看下面的演示。
开始的时候和上一版一样,当 workCount < corePoolSize 时,通过创建新的 Worker 来执行任务。
当 workCount >= corePoolSize 就停止创建新线程,把任务直接丢到队列里。
但当队列已满且仍然 workCount < maximumPoolSize 时,不再直接走拒绝策略,而是创建非核心线程,直到 workCount = maximumPoolSize,再走拒绝策略。
小宇:哎呀,我怎么没想到,这样 corePoolSize 就负责平时大多数情况所需要的工作线程数,而 maximumPoolSize 就负责在高峰期临时扩充工作线程数。
闪客:没错,高峰时期的弹性搞定了,那自然就还要考虑低谷时期。当长时间没有任务提交时,核心线程与非核心线程都一直空跑着,浪费资源。我们可以给非核心线程设定一个超时时间 keepAliveTime,当这么长时间没能从队列里获取任务时,就不再等了,销毁线程。
小宇:嗯,这回咱们的线程池在 QPS 高峰时可以临时扩容,QPS 低谷时又可以及时回收线程(非核心线程)而不至于浪费资源,真的显得十分 Q 弹呢。
闪客:是呀是呀。诶不对,怎么又变成我说了,不是说这一版你来思考么?
小宇:我也想啊,但你这一讲技术就自说自话的毛病老是不改,我有啥办法。
闪客:额抱歉抱歉,那接下来你总结一下我们的线程池吧
小宇:嗯好的,首先它的构造方法是这个样子滴
1 | public FlashExecutor( |
这些参数分别是
int corePoolSize:核心线程数
int maximumPoolSize:最大线程数
long keepAliveTime:非核心线程的空闲时间
TimeUnit unit:空闲时间的单位
BlockingQueue
ThreadFactory threadFactory:线程工厂
RejectedExecutionHandler handler:拒绝策略
整个任务的提交流程是
闪客:不错不错,这可是你自己总结的哟,现在还用我给你讲什么是线程池了么?
小宇:啊天呢,我才发现这似乎就是我一直弄不清楚的线程池的参数和原理呢!
闪客:没错,而且最后一版代码的构造方法,就是 Java 面试常考的 ThreadPoolExecutor 最长的那个构造方法,参数名都没变。
小宇:哇,太赞了!我都忘了一开始我想干嘛了,嘻嘻。
闪客:哈哈,不知不觉学到了技术才爽呢,对吧?晚饭时间快到了,要不要一块去吃山西面馆呀?
小宇:哦,那家店餐桌的颜色我不太喜欢,下次吧。
闪客:哦好吧。
]]>很久很久之前,你不与任何其他电脑相连接,孤苦伶仃。
直到有一天,你希望与另一台电脑 B 建立通信,于是你们各开了一个网口,用一根网线连接了起来。
用一根网线连接起来怎么就能"通信"了呢?我可以给你讲 IO、讲中断、讲缓冲区,但这不是研究网络时该关心的问题。
如果你纠结,要么去研究一下操作系统是如何处理网络 IO 的,要么去研究一下包是如何被网卡转换成电信号发送出去的,要么就仅仅把它当做电脑里有个小人在开枪吧~
反正,你们就是连起来了,并且可以通信。
有一天,一个新伙伴 C 加入了,但聪明的你们很快发现,可以每个人开两个网口,用一共三根网线,彼此相连。
随着越来越多的人加入,你发现身上开的网口实在太多了,而且网线密密麻麻,混乱不堪。(而实际上一台电脑根本开不了这么多网口,所以这种连线只在理论上可行,所以连不上的我就用红色虚线表示了,就是这么严谨哈哈~)
于是你们发明了一个中间设备,你们将网线都插到这个设备上,由这个设备做转发,就可以彼此之间通信了,本质上和原来一样,只不过网口的数量和网线的数量减少了,不再那么混乱。
你给它取名叫集线器,它仅仅是无脑将电信号转发到所有出口(广播),不做任何处理,你觉得它是没有智商的,因此把人家定性在了物理层。
由于转发到了所有出口,那 BCDE 四台机器怎么知道数据包是不是发给自己的呢?
首先,你要给所有的连接到集线器的设备,都起个名字。原来你们叫 ABCD,但现在需要一个更专业的,全局唯一的名字作为标识,你把这个更高端的名字称为 MAC 地址。
你的 MAC 地址是 aa-aa-aa-aa-aa-aa,你的伙伴 b 的 MAC 地址是 bb-bb-bb-bb-bb-bb,以此类推,不重复就好。
这样,A 在发送数据包给 B 时,只要在头部拼接一个这样结构的数据,就可以了。
B 在收到数据包后,根据头部的目标 MAC 地址信息,判断这个数据包的确是发给自己的,于是便收下。
其他的 CDE 收到数据包后,根据头部的目标 MAC 地址信息,判断这个数据包并不是发给自己的,于是便丢弃。
虽然集线器使整个布局干净不少,但原来我只要发给电脑 B 的消息,现在却要发给连接到集线器中的所有电脑,这样既不安全,又不节省网络资源。
如果把这个集线器弄得更智能一些,只发给目标 MAC 地址指向的那台电脑,就好了。
虽然只比集线器多了这一点点区别,但看起来似乎有智能了,你把这东西叫做交换机。也正因为这一点点智能,你把它放在了另一个层级,数据链路层。
如上图所示,你是这样设计的。
交换机内部维护一张 MAC 地址表,记录着每一个 MAC 地址的设备,连接在其哪一个端口上。
MAC 地址 | 端口 |
---|---|
bb-bb-bb-bb-bb-bb | 1 |
cc-cc-cc-cc-cc-cc | 3 |
aa-aa-aa-aa-aa-aa | 4 |
dd-dd-dd-dd-dd-dd | 5 |
假如你仍然要发给 B 一个数据包,构造了如下的数据结构从网口出去。
到达交换机时,交换机内部通过自己维护的 MAC 地址表,发现目标机器 B 的 MAC 地址 bb-bb-bb-bb-bb-bb 映射到了端口 1 上,于是把数据从 1 号端口发给了 B,完事~
你给这个通过这样传输方式而组成的小范围的网络,叫做以太网。
当然最开始的时候,MAC 地址表是空的,是怎么逐步建立起来的呢?
假如在 MAC 地址表为空是,你给 B 发送了如下数据
由于这个包从端口 4 进入的交换机,所以此时交换机就可以在 MAC地址表记录第一条数据:
MAC:aa-aa-aa-aa-aa-aa-aa
端口:4
交换机看目标 MAC 地址(bb-bb-bb-bb-bb-bb)在地址表中并没有映射关系,于是将此包发给了所有端口,也即发给了所有机器。
之后,只有机器 B 收到了确实是发给自己的包,于是做出了响应,响应数据从端口 1 进入交换机,于是交换机此时在地址表中更新了第二条数据:
MAC:bb-bb-bb-bb-bb-bb
端口:1
过程如下
经过该网络中的机器不断地通信,交换机最终将 MAC 地址表建立完毕~
随着机器数量越多,交换机的端口也不够了,但聪明的你发现,只要将多个交换机连接起来,这个问题就轻而易举搞定~
你完全不需要设计额外的东西,只需要按照之前的设计和规矩来,按照上述的接线方式即可完成所有电脑的互联,所以交换机设计的这种规则,真的很巧妙。你想想看为什么(比如 A 要发数据给 F)。
但是你要注意,上面那根红色的线,最终在 MAC 地址表中可不是一条记录呀,而是要把 EFGH 这四台机器与该端口(端口6)的映射全部记录在表中。
最终,两个交换机将分别记录 A ~ H 所有机器的映射记录。
左边的交换机
MAC 地址 | 端口 |
---|---|
bb-bb-bb-bb-bb-bb | 1 |
cc-cc-cc-cc-cc-cc | 3 |
aa-aa-aa-aa-aa-aa | 4 |
dd-dd-dd-dd-dd-dd | 5 |
ee-ee-ee-ee-ee-ee | 6 |
ff-ff-ff-ff-ff-ff | 6 |
gg-gg-gg-gg-gg-gg | 6 |
hh-hh-hh-hh-hh-hh | 6 |
右边的交换机
MAC 地址 | 端口 |
---|---|
bb-bb-bb-bb-bb-bb | 1 |
cc-cc-cc-cc-cc-cc | 1 |
aa-aa-aa-aa-aa-aa | 1 |
dd-dd-dd-dd-dd-dd | 1 |
ee-ee-ee-ee-ee-ee | 2 |
ff-ff-ff-ff-ff-ff | 3 |
gg-gg-gg-gg-gg-gg | 4 |
hh-hh-hh-hh-hh-hh | 6 |
这在只有 8 台电脑的时候还好,甚至在只有几百台电脑的时候,都还好,所以这种交换机的设计方式,已经足足支撑一阵子了。
但很遗憾,人是贪婪的动物,很快,电脑的数量就发展到几千、几万、几十万。
交换机已经无法记录如此庞大的映射关系了。
此时你动了歪脑筋,你发现了问题的根本在于,连出去的那根红色的网线,后面不知道有多少个设备不断地连接进来,从而使得地址表越来越大。
那我可不可以让那根红色的网线,接入一个新的设备,这个设备就跟电脑一样有自己独立的 MAC 地址,而且同时还能帮我把数据包做一次转发呢?
这个设备就是路由器,它的功能就是,作为一台独立的拥有 MAC 地址的设备,并且可以帮我把数据包做一次转发**,你把它定在了网络层**。
注意,路由器的每一个端口,都有独立的 MAC 地址
好了,现在交换机的 MAC 地址表中,只需要多出一条 MAC 地址 ABAB 与其端口的映射关系,就可以成功把数据包转交给路由器了,这条搞定。
那如何做到,把发送给 C 和 D,甚至是把发送给 DEFGH… 的数据包,统统先发送给路由器呢?
不难想到这样一个点子,假如电脑 C 和 D 的 MAC 地址拥有共同的前缀,比如分别是
C 的 MAC 地址:FFFF-FFFF-CCCC
D 的 MAC 地址:FFFF-FFFF-DDDD
那我们就可以说,将目标 MAC 地址为 FFFF-FFFF-?开头的,统统先发送给路由器。
这样是否可行呢?答案是否定的。
我们先从现实中 MAC 地址的结构入手,MAC地址也叫物理地址、硬件地址,长度为 48 位,一般这样来表示
00-16-EA-AE-3C-40
它是由网络设备制造商生产时烧录在网卡的EPROM(一种闪存芯片,通常可以通过程序擦写)。其中**前 24 位(00-16-EA)代表网络硬件制造商的编号****,后 24 位(AE-3C-40)是该厂家自己分配的,一般表示系列号。**只要不更改自己的 MAC 地址,MAC 地址在世界是唯一的。形象地说,MAC地址就如同身份证上的身份证号码,具有唯一性。
那如果你希望向上面那样表示将目标 MAC 地址为 FFFF-FFFF-?开头的,统一从路由器出去发给某一群设备(后面会提到这其实是子网的概念),那你就需要要求某一子网下统统买一个厂商制造的设备,要么你就需要要求厂商在生产网络设备烧录 MAC 地址时,提前按照你规划好的子网结构来定 MAC 地址,并且日后这个网络的结构都不能轻易改变。
这显然是不现实的。
于是你发明了一个新的地址,给每一台机器一个 32 位的编号,如:
11000000101010000000000000000001
你觉得有些不清晰,于是把它分成四个部分,中间用点相连。
11000000.10101000.00000000.00000001
你还觉得不清晰,于是把它转换成 10 进制。
192.168.0.1
最后你给了这个地址一个响亮的名字,IP 地址。现在每一台电脑,同时有自己的 MAC 地址,又有自己的 IP 地址,只不过 IP 地址是软件层面上的,可以随时修改,MAC 地址一般是无法修改的。
这样一个可以随时修改的 IP 地址,就可以根据你规划的网络拓扑结构,来调整了。
如上图所示,假如我想要发送数据包给 ABCD 其中一台设备,不论哪一台,我都可以这样描述,“将 IP 地址为 192.168.0 开头的全部发送给到路由器,之后再怎么转发,交给它!”,巧妙吧。
那交给路由器之后,路由器又是怎么把数据包准确转发给指定设备的呢?
别急我们慢慢来。
我们先给上面的组网方式中的每一台设备,加上自己的 IP 地址
现在两个设备之间传输,除了加上数据链路层的头部之外,还要再增加一个网络层的头部。
假如 A 给 B 发送数据,由于它们直接连着交换机,所以 A 直接发出如下数据包即可,其实网络层没有体现出作用。
但假如 A 给 C 发送数据,A 就需要先转交给路由器,然后再由路由器转交给 C。由于最底层的传输仍然需要依赖以太网,所以数据包是分成两段的。
A ~ 路由器这段的包如下:
路由器到 C 这段的包如下:
好了,上面说的两种情况(A->B,A->C),相信细心的读者应该会有不少疑问,下面我们一个个来展开。
答案:子网
如果源 IP 与目的 IP 处于一个子网,直接将包通过交换机发出去。
如果源 IP 与目的 IP 不处于一个子网,就交给路由器去处理。
好,那现在只需要解决,什么叫处于一个子网就好了。
这两个是我们人为规定的,即我们想表示,对于 192.168.0.1 来说:
192.168.0.xxx 开头的,就算是在一个子网,否则就是在不同的子网。
那对于计算机来说,怎么表达这个意思呢?于是人们发明了子网掩码的概念
假如某台机器的子网掩码定为 255.255.255.0
这表示,将源 IP 与目的 IP 分别同这个子网掩码进行与运算**,相等则是在一个子网,不相等就是在不同子网,就这么简单。
比如
那么 A 与 B 在同一个子网,C 与 D 在同一个子网,但是 A 与 C 就不在同一个子网,与 D 也不在同一个子网,以此类推。
所以如果 A 给 C 发消息,A 和 C 的 IP 地址分别 & A 机器配置的子网掩码,发现不相等,则 A 认为 C 和自己不在同一个子网,于是把包发给路由器,就不管了,之后怎么转发,A 不关心。
答案:在 A 上要设置默认网关
上一步 A 通过是否与 C 在同一个子网内,判断出自己应该把包发给路由器,那路由器的 IP 是多少呢?
其实说发给路由器不准确,应该说 A 会把包发给默认网关。
对 A 来说,A 只能直接把包发给同处于一个子网下的某个 IP 上,所以发给路由器还是发给某个电脑,对 A 来说也不关心,只要这个设备有个 IP 地址就行。
所以默认网关,就是 A 在自己电脑里配置的一个 IP 地址,以便在发给不同子网的机器时,发给这个 IP 地址。
仅此而已!
答案:路由表
现在 A 要给 C 发数据包,已经可以成功发到路由器这里了,最后一个问题就是,路由器怎么知道,收到的这个数据包,该从自己的哪个端口出去,才能直接(或间接)地最终到达目的地 C 呢。
路由器收到的数据包有目的 IP 也就是 C 的 IP 地址,需要转化成从自己的哪个端口出去,很容易想到,应该有个表,就像 MAC 地址表一样。
这个表就叫路由表。
至于这个路由表是怎么出来的,有很多路由算法,本文不展开,因为我也不会哈哈~
不同于 MAC 地址表的是,路由表并不是一对一这种明确关系,我们下面看一个路由表的结构。
目的地址 | 子网掩码 | 下一跳 | 端口 |
---|---|---|---|
192.168.0.0 | 255.255.255.0 | 0 | |
192.168.0.254 | 255.255.255.255 | 0 | |
192.168.1.0 | 255.255.255.0 | 1 | |
192.168.1.254 | 255.255.255.255 | 1 |
我们学习一种新的表示方法,由于子网掩码其实就表示前多少位表示子网的网段,所以如 192.168.0.0(255.255.255.0) 也可以简写为 192.168.0.0/24
目的地址 | 下一跳 | 端口 |
---|---|---|
192.168.0.0/24 | 0 | |
192.168.0.254/32 | 0 | |
192.168.1.0/24 | 1 | |
192.168.1.254/32 | 1 |
这就很好理解了,路由表就表示,192.168.0.xxx 这个子网下的,都转发到 0 号端口,192.168.1.xxx 这个子网下的,都转发到 1 号端口。下一跳列还没有值,我们先不管
配合着结构图来看(这里把子网掩码和默认网关都补齐了)图中 & 笔误,结果应该是 .0
答案:arp
假如你(A)此时不知道你同伴 B 的 MAC 地址(现实中就是不知道的,刚刚我们只是假设已知),你只知道它的 IP 地址,你该怎么把数据包准确传给 B 呢?
答案很简单,在网络层,我需要把 IP 地址对应的 MAC 地址找到,也就是通过某种方式,找到 192.168.0.2 对应的 MAC 地址 BBBB。
这种方式就是 arp 协议,同时电脑 A 和 B 里面也会有一张 arp 缓存表,表中记录着 IP 与 MAC 地址的对应关系。
IP 地址 | MAC 地址 |
---|---|
192.168.0.2 | BBBB |
一开始的时候这个表是空的,电脑 A 为了知道电脑 B(192.168.0.2)的 MAC 地址,将会广播一条 arp 请求,B 收到请求后,带上自己的 MAC 地址给 A 一个响应。此时 A 便更新了自己的 arp 表。
这样通过大家不断广播 arp 请求,最终所有电脑里面都将 arp 缓存表更新完整。
总结一下
好了,总结一下,到目前为止就几条规则
从各个节点的视角来看
电脑视角:
交换机视角:
路由器视角:
如果你嗅觉足够敏锐,你应该可以感受到下面这句话:
网络层(IP协议)本身没有传输包的功能,包的实际传输是委托给数据链路层(以太网中的交换机)来实现的。
涉及到的三张表分别是
这三张表是怎么来的
知道了以上这些,目前网络上两个节点是如何发送数据包的这个过程,就完全可以解释通了!
那接下来我们就放上本章 最后一个 网络拓扑图吧,请做好 战斗 准备!
这时路由器 1 连接了路由器 2,所以其路由表有了下一条地址这一个概念,所以它的路由表就变成了这个样子。如果匹配到了有下一跳地址的一项,则需要再次匹配,找到其端口,并找到下一跳 IP 的 MAC 地址。
也就是说找来找去,最终必须能映射到一个端口号,然后从这个端口号把数据包发出去。
目的地址 | 下一跳 | 端口 |
---|---|---|
192.168.0.0/24 | 0 | |
192.168.0.254/32 | 0 | |
192.168.1.0/24 | 1 | |
192.168.1.254/32 | 1 | |
192.168.2.0/24 | 192.168.100.5 | |
192.168.100.0/24 | 2 | |
192.168.100.4/32 | 2 |
这时如果 A 给 F 发送一个数据包,能不能通呢?如果通的话整个过程是怎样的呢?
思考一分钟…
详细过程动画描述:
详细过程文字描述:
1. 首先 A(192.168.0.1)通过子网掩码(255.255.255.0)计算出自己与 F(192.168.2.2)并不在同一个子网内,于是决定发送给默认网关(192.168.0.254)
2. A 通过 ARP 找到 默认网关 192.168.0.254 的 MAC 地址。
3. A 将源 MAC 地址(AAAA)与网关 MAC 地址(ABAB)封装在数据链路层头部,又将源 IP 地址(192.168.0.1)和目的 IP 地址(192.168.2.2)(注意这里千万不要以为填写的是默认网关的 IP 地址,从始至终这个数据包的两个 IP 地址都是不变的,只有 MAC 地址在不断变化)封装在网络层头部,然后发包
4. 交换机 1 收到数据包后,发现目标 MAC 地址是 ABAB,转发给路由器1
5. 数据包来到了路由器 1,发现其目标 IP 地址是 192.168.2.2,查看其路由表,发现了下一跳的地址是 192.168.100.5
6. 所以此时路由器 1 需要做两件事,第一件是再次匹配路由表,发现匹配到了端口为 2,于是将其封装到数据链路层,最后把包从 2 号口发出去。
7. 此时路由器 2 收到了数据包,看到其目的地址是 192.168.2.2,查询其路由表,匹配到端口号为 1,准备从 1 号口把数据包送出去。
8. 但此时路由器 2 需要知道 192.168.2.2 的 MAC 地址了,于是查看其 arp 缓存,找到其 MAC 地址为 FFFF,将其封装在数据链路层头部,并从 1 号端口把包发出去。
9. 交换机 3 收到了数据包,发现目的 MAC 地址为 FFFF,查询其 MAC 地址表,发现应该从其6 号端口出去,于是从 6 号端口把数据包发出去。
10. F 最终收到了数据包!并且发现目的 MAC 地址就是自己,于是收下了这个包
至此,经过物理层、数据链路层、网络层这前三层的协议,以及根据这些协议设计的各种网络设备(网线、集线器、交换机、路由器),理论上只要拥有对方的 IP 地址,就已经将地球上任意位置的两个节点连通了。
]]>profiler
命令支持生成应用热点的火焰图。本质上是通过不断的采样,然后把收集到的采样结果生成火焰图。
目前 profiler 命令还不支持在 Windows 下执行。
1 | profiler list |
profiler
命令基本运行结构是 profiler action [actionArg]
参数名称 | 参数说明 |
---|---|
action | 要执行的操作 |
actionArg | 属性名模式 |
[i:] | 采样间隔(单位:ns)(默认值:10’000’000,即10 ms) |
[f:] | 将输出转储到指定路径 |
[d:] | 运行评测指定秒 |
[e:] | 要跟踪哪个事件(cpu, alloc, lock, cache-misses等),默认是 cpu |
(1)启动 profiler
1 | profiler start |
默认情况下,生成的是cpu的火焰图,即event为
cpu
。可以用--event
参数来指定。
(2)获取已采集的 sample 的数量
1 | profiler getSamples |
(3)查看 profiler 状态
可以查看当前 profiler 在采样哪种 event
和采样时间。
1 | profiler status |
(4)停止 profiler
默认情况下,生成的格式为 svg 格式,且生成的结果保存到应用的 工作目录
下的 arthas-output
目录。
1 | Started [cpu] profiling |
可以通过 --file
参数来指定输出结果路径。比如:
1 | profiler stop --file ./output.svg |
如果需要生成 html 格式的,可以用 --format
参数指定:
1 | profiler stop --format html |
默认情况下,arthas使用3658端口,则可以打开: http://localhost:3658/arthas-output/ 查看到arthas-output
目录下面的 profiler 结果,或者直接打开源文件即可。
在不同的平台,不同的OS下面,支持的events各有不同。可以通过 profiler list
命令查询。
1 | profiler resume |
start
和resume
的区别是:start
是新开始采样,resume
会保留上次stop
时的数据。
通过执行profiler getSamples
可以查看 samples 的数量来验证。
如果遇到生成的svg图片有 [frame_buffer_overflow]
,则需要增大 framebuf(默认值是 1’000’000),可以显式配置,比如:
1 | profiler start --framebuf 5000000 |
如果应用比较复杂,生成的内容很多,想只关注部分数据,可以通过 include/exclude 来过滤。比如:
1 | profiler start --include 'java/*' --include 'demo/*' --exclude '*Unsafe.park*' |
include/exclude 都支持设置多个值 ,但是需要配置在命令行的最后。
比如,希望 profiler 执行 300 秒自动结束,可以用 -d
/--duration
参数指定:
1 | profiler start --duration 300 |
jfr 只支持在 start
时配置。如果是在 stop
时指定,则不会生效。
1 | profiler start --file /tmp/test.jfr |
file
参数支持一些变量:
--file /tmp/test-%t.jfr
--file /tmp/test-%p.jfr
生成的结果可以用支持jfr格式的工具来查看。比如:
火焰图是基于 perf 结果产生的SVG 图片,用来展示 CPU 的调用栈。
y 轴表示调用栈,每一层都是一个函数。调用栈越深,火焰就越高,顶部就是正在执行的函数,下方都是它的父函数。
x 轴表示抽样数,如果一个函数在 x 轴占据的宽度越宽,就表示它被抽到的次数多,即执行的时间长。注意,x 轴不代表时间,而是所有的调用栈合并后,按字母顺序排列的。
火焰图就是看顶层的哪个函数占据的宽度最大。只要有"平顶"(plateaus),就表示该函数可能存在性能问题。
颜色没有特殊含义,因为火焰图表示的是 CPU 的繁忙程度,所以一般选择暖色调。
]]>可以参考阮一峰的文章:http://www.ruanyifeng.com/blog/2017/09/flame-graph.html
在本章节中,将学习以下 Arthas 的核心命令,同时我也会附上官方文档的链接,方便大家查阅:
monitor 方法执行监控
watch 方法执行数据观测
trace 方法内部调用路径,并输出方法路径上的每个节点上耗时
stack 输出当前方法被调用的调用路径
tt 记录下指定方法每次调用的入参和返回信息,并能对这些不同时间下调用的信息进行观测
watch/trace/monitor/stack/tt 命令都支持 -v
参数。当命令执行之后,没有输出结果。有两种可能:
但用户区分不出是哪种情况。使用 -v
选项,则会打印 Condition express
的具体值和执行结果,方便确认。
对匹配 class-pattern
/method-pattern
/condition-express
的类、方法的调用进行监控。
monitor
命令是一个非实时返回命令。
实时返回命令是输入之后立即返回,而非实时返回的命令,则是不断的等待目标 Java 进程返回信息,直到用户输入
Ctrl+C
为止。
服务端是以任务的形式在后台跑任务,植入的代码随着任务的中止而不会被执行,所以任务关闭后,不会对原有性能产生太大影响,而且原则上,任何 Arthas 命令不会引起原有业务逻辑的改变。
参数名称 | 参数说明 |
---|---|
class-pattern | 类名表达式匹配 |
method-pattern | 方法名表达式匹配 |
condition-express | 条件表达式 |
[E] | 开启正则表达式匹配,默认为通配符匹配 |
[c:] | 统计周期,默认值为120秒 |
[b] | 在方法调用之前计算condition-express |
监控项 | 说明 |
---|---|
timestamp | 时间戳 |
class | Java类 |
method | 方法(构造方法、普通方法) |
total | 调用次数 |
success | 成功次数 |
fail | 失败次数 |
rt | 平均RT |
fail-rate | 失败率 |
每隔 5 秒监控一次类 demo.MathGame 中的 primeFactors 方法:
1 | monitor -c 5 demo.MathGame primeFactors |
方法执行后,每隔 5 秒监控一次类 demo.MathGame 中的 primeFactors 方法,筛选第一个参数 <= 2 的数据:
1 | monitor -c 5 demo.MathGame primeFactors "params[0] <= 2" |
方法执行前,每隔 5 秒监控一次类 demo.MathGame 中的 primeFactors 方法,筛选第一个参数 <= 2 的数据:
1 | monitor -b -c 5 demo.MathGame primeFactors "params[0] <= 2" |
能方便的观察到指定方法的调用情况。能观察到的范围为:返回值
、抛出异常
、入参
,通过编写 OGNL 表达式进行对应变量的查看。
watch 的参数比较多,主要是因为它能在 4 个不同的场景观察对象:
参数名称 | 参数说明 |
---|---|
class-pattern | 类名表达式匹配 |
method-pattern | 方法名表达式匹配 |
express | 观察表达式 |
condition-express | 条件表达式 |
[b] | 在方法调用之前观察 |
[e] | 在方法异常之后观察 |
[s] | 在方法返回之后观察 |
[f] | 在方法结束之后(正常返回和异常返回)观察 |
[E] | 开启正则表达式匹配,默认为通配符匹配 |
[x:] | 指定输出结果的属性遍历深度,默认为 1 |
这里重点要说明的是观察表达式,观察表达式的构成主要由 ognl 表达式组成,所以你可以这样写 "{params,returnObj}"
,只要是一个合法的 ognl 表达式,都能被正常支持。
-b
方法调用前,-e
方法异常后,-s
方法返回后,-f
方法结束后-b
、-e
、-s
默认关闭,-f
默认打开,当指定观察点被打开后,在相应事件点会对观察表达式进行求值并输出方法入参
和 方法出参
的区别,有可能在中间被修改导致前后不一致,除了 -b
事件点 params
代表方法入参外,其余事件都代表方法出参-b
时,由于观察事件点是在方法调用前,此时返回值或异常均不存在观察 demo.MathGame 类中 primeFactors 方法出参和返回值,结果属性遍历深度为 2。
params 表示所有参数数组(因为不确定是几个参数),returnObject 表示返回值。
1 | watch demo.MathGame primeFactors "{params,returnObj}" -x 2 |
观察方法入参,对比前一个例子,返回值为空(事件点为方法执行前,因此获取不到返回值)
1 | watch demo.MathGame primeFactors "{params,returnObj}" -x 2 -b |
同时观察方法调用前和方法返回后,参数里 -n 2
,表示只执行两次(一前一后)。
这里输出结果中,第一次输出的是方法调用前的观察表达式的结果,第二次输出的是方法返回后的表达式的结果
params 表示参数,target 表示执行方法的对象,returnObject 表示返回值
结果的输出顺序和事件发生的先后顺序一致,和命令中 -s -b
的顺序无关
1 | watch demo.MathGame primeFactors "{params,target,returnObj}" -x 2 -b -s -n 2 |
1 | watch demo.MathGame primeFactors 'target' -x 2 -n 1 |
如果觉得深度不够的话,可以调整 -x 的值,来看的更仔细。
然后使用 target.field_name
访问当前对象的某个属性
1 | watch demo.MathGame primeFactors 'target.illegalArgumentCount' -x 2 -n 1 |
只有满足条件表达式的调用,才会有响应。监控第一个出参小于 0 的调用。
1 | watch demo.MathGame primeFactors "{params[0],target}" "params[0]<0" |
监控抛出异常的调用,并打印第一个出参:
1 | watch demo.MathGame primeFactors "{params[0],throwExp}" -e -x 2 |
-e
表示抛出异常时才触发throwExp
#cost>200
(单位是ms
)表示只有当耗时大于 200ms 时才会输出,过滤掉执行时间小于 200ms 的调用:
1 | watch demo.MathGame primeFactors '{params, returnObj}' '#cost>200' -x 2 |
watch / stack / trace 这个三个命令都支持
#cost
trace
命令能主动搜索 class-pattern
/ method-pattern
对应的方法调用路径,渲染和统计整个调用链路上的所有性能开销和追踪调用链路。
参数名称 | 参数说明 |
---|---|
class-pattern | 类名表达式匹配 |
method-pattern | 方法名表达式匹配 |
condition-express | 条件表达式 |
[E] | 开启正则表达式匹配,默认为通配符匹配 |
[n:] | 命令执行次数 |
#cost | 方法执行耗时 |
这里重点要说明的是观察表达式,观察表达式的构成主要由 ognl 表达式组成,所以你可以这样写 "{params,returnObj}"
,只要是一个合法的 ognl 表达式,都能被正常支持。
trace
能方便的帮助你定位和发现因 RT 高而导致的性能问题缺陷,但其每次只能跟踪一级方法的调用链路。
参考:Trace命令的实现原理
3.3.0 起支持使用动态 Trace 功能,不断增加新的匹配类,参考下面的示例。
trace 函数指定类的指定方法:
1 | trace demo.MathGame run |
如果方法调用的次数很多,那么可以用 -n 参数指定捕捉结果的次数:
1 | trace demo.MathGame run -n 1 |
默认情况下,trace 不会包含 JDK 里的函数调用,如果希望 trace JDK 里的函数,需要显式设置 --skipJDKMethod false
:
1 | trace --skipJDKMethod false demo.MathGame run |
只会展示耗时大于 1ms 的调用路径,有助于在排查问题的时候,只关注异常情况。
1 | trace demo.MathGame run '#cost > 1' |
trace
在执行的过程中本身是会有一定的性能开销,在统计的报告中并未像 JProfiler 一样预先减去其自身的统计开销。所以这统计出来有些许的不准,渲染路径上调用的类、方法越多,性能偏差越大。但还是能让你看清一些事情的。trace 命令只会 trace 匹配到的函数里的子调用,并不会向下 trace 多层。因为 trace 是代价比较贵的,多层 trace 可能会导致最终要 trace 的类和函数非常多。
可以用正则表匹配路径上的多个类和函数,一定程度上达到多层 trace 的效果。
1 | trace -E com.test.ClassA|org.test.ClassB method1|method2|method3 |
打开终端1,trace 上面 demo 里的 run
函数,可以看到打印出 listenerId: 1
:
1 | [arthas@59161]$ trace demo.MathGame run |
现在想要深入子函数 primeFactors
,可以打开一个新终端2,使用 telnet localhost 3658
连接上arthas,再 trace primeFactors
时,指定 listenerId
。
1 | [arthas@59161]$ trace demo.MathGame primeFactors --listenerId 1 |
这时终端 2 打印的结果,说明已经增强了一个函数:Affect(class count: 1 , method count: 1)
,但不再打印更多的结果。
再查看终端1,可以发现trace的结果增加了一层,打印了primeFactors
函数里的内容:
1 | `---ts=2020-07-09 16:49:29;thread_name=main;id=1;is_daemon=false;priority=5;TCCL=sun.misc.Launcher$AppClassLoader@3d4eac69 |
通过指定listenerId
的方式动态 trace,可以不断深入。另外 watch
/tt
/monitor
等命令也支持类似的功能。
很多时候我们都知道一个方法被执行,但这个方法被执行的路径非常多,或者你根本就不知道这个方法是从那里被执行了,此时你需要的是 stack 命令。
参数名称 | 参数说明 |
---|---|
class-pattern | 类名表达式匹配 |
method-pattern | 方法名表达式匹配 |
condition-express | 条件表达式 |
[E] | 开启正则表达式匹配,默认为通配符匹配 |
[n:] | 执行次数限制 |
这里重点要说明的是观察表达式,观察表达式的构成主要由 ognl 表达式组成,所以你可以这样写 "{params,returnObj}"
,只要是一个合法的 ognl 表达式,都能被正常支持。
1 | stack demo.MathGame primeFactors |
第 0 个参数的值小于 0,-n 表示获取 2 次
1 | stack demo.MathGame primeFactors 'params[0]<0' -n 2 |
过滤耗时大于0.5毫秒
1 | stack demo.MathGame primeFactors '#cost>0.5' |
watch
虽然很方便和灵活,但需要提前想清楚观察表达式的拼写,这对排查问题而言要求太高,因为很多时候我们并不清楚问题出自于何方,只能靠蛛丝马迹进行猜测。
这个时候如果能记录下当时方法调用的所有入参和返回值、抛出的异常会对整个问题的思考与判断非常有帮助。
于是乎,TimeTunnel (时间隧道)命令就诞生了。
参数名称 | 参数说明 |
---|---|
-t | 记录某个方法在一个时间段中的调用 |
-l | 显示所有已经记录的列表 |
-n 次数 | 只记录多少次 |
-s 表达式 | 搜索表达式 |
-i 索引号 | 查看指定索引号的详细调用信息 |
-p | 重新调用指定的索引号时间碎片 |
最基本的使用来说,就是记录下当前方法的每次调用环境现场
1 | tt -t demo.MathGame primeFactors |
表格字段 | 字段解释 |
---|---|
INDEX | 时间片段记录编号,每一个编号代表着一次调用,后续tt还有很多命令都是基于此编号指定记录操作,非常重要。 |
TIMESTAMP | 方法执行的本机时间,记录了这个时间片段所发生的本机时间 |
COST(ms) | 方法执行的耗时 |
IS-RET | 方法是否以正常返回的形式结束 |
IS-EXP | 方法是否以抛异常的形式结束 |
OBJECT | 执行对象的 hashCode() ,注意,曾经有人误认为是对象在 JVM 中的内存地址,但很遗憾它不是,但它能帮助你简单的标记当前执行方法的类实体。 |
CLASS | 执行的类名 |
METHOD | 执行的方法名 |
条件表达式
不知道大家是否有在使用过程中遇到以下困惑
条件表达式也是用 OGNL
来编写,核心的判断对象依然是 Advice
对象。除了 tt
命令之外,watch
、trace
、stack
命令也都支持条件表达式。
解决方法重载
tt -t *Test print params.length==1
通过指定参数个数的形式解决不同的方法签名,如果参数个数一样,你还可以这样写
tt -t *Test print 'params[1] instanceof Integer'
解决指定参数
tt -t *Test print params[0].mobile=="13989838402"
当你用 tt
记录了一大片的时间片段之后,你希望能从中筛选出自己需要的时间片段,这个时候你就需要对现有记录进行检索。
1 | tt -l |
筛选出异常方法的调用信息:
1 | tt -s "isThrow==true" |
对于具体一个时间片的信息而言,你可以通过 -i
参数后边跟着对应的 INDEX
编号查看到他的详细信息。
1 | tt -i 1000 |
当你稍稍做了一些调整之后,你可能需要前端系统重新触发一次你的调用,此时得求爷爷告奶奶的需要前端配合联调的同学再次发起一次调用。而有些场景下,这个调用不是这么好触发的。
tt
命令由于保存了当时调用的所有现场信息,所以我们可以自己主动对一个 INDEX
编号的时间片自主发起一次调用,从而解放你的沟通成本。此时你需要 -p
参数。通过 --replay-times
指定 调用次数,通过 --replay-interval
指定多次调用间隔(单位ms, 默认1000ms)
你会发现结果虽然一样,但调用的路径发生了变化,由原来的程序发起变成了 Arthas 自己的内部线程发起的调用了。
需要强调的点:
ThreadLocal 信息丢失
很多框架偷偷的将一些环境变量信息塞到了发起调用线程的 ThreadLocal 中,由于调用线程发生了变化,这些 ThreadLocal 线程信息无法通过 Arthas 保存,所以这些信息将会丢失。
引用的对象
需要强调的是,tt
命令是将当前环境的对象引用保存起来,但仅仅也只能保存一个引用而已。如果方法内部对入参进行了变更,或者返回的对象经过了后续的处理,那么在 tt
查看的时候将无法看到当时最准确的值。这也是为什么 watch
命令存在的意义。
在本章节中,将学习以下 Arthas 的 Class 相关命令,同时我也会附上官方文档的链接,方便大家查阅:
sc
是 Search-Class 的缩写,用于查看 JVM 已加载的类信息,这个命令能搜索出所有已经加载到 JVM 中的 Class 信息。
class-pattern支持全限定名,如com.taobao.test.AAA,也支持com/taobao/test/AAA 这样的格式,这样,我们从异常堆栈里面把类名拷贝过来的时候,不需要在手动把
/
替换为.
sc 默认开启了子类匹配功能,也就是说所有当前类的子类也会被搜索出来,想要精确的匹配,请打开
options disable-sub-class true
开关。
参数名称 | 参数说明 |
---|---|
class-pattern | 类名表达式匹配 |
method-pattern | 方法名表达式匹配 |
[d] | 输出当前类的详细信息,包括这个类所加载的原始文件来源、类的声明、加载的ClassLoader等详细信息。 如果一个类被多个ClassLoader所加载,则会出现多次 |
[E] | 开启正则表达式匹配,默认为通配符匹配 |
[f] | 输出当前类的成员变量信息(需要配合参数-d一起使用) |
[x:] | 指定输出静态变量时属性的遍历深度,默认为 0,即直接使用 toString 输出 |
[c:] | 指定class的 ClassLoader 的 hashcode |
[classLoaderClass:] | 指定执行表达式的 ClassLoader 的 class name |
[n:] | 具有详细信息的匹配类的最大数量(默认为100) |
(1)模糊搜索,demo 包下所有的类
1 | sc demo.* |
(2)打印 demo.MathGame 类的详细信息
(3)打印 demo.MathGame 类的详细信息 + 变量信息
sm
是 Search-Method 的简写,这个命令能搜索出所有已经加载了 Class 信息的方法信息。
sm
命令只能看到由当前类所声明 (declaring) 的方法,父类则无法看到。
参数名称 | 参数说明 |
---|---|
class-pattern | 类名表达式匹配 |
method-pattern | 方法名表达式匹配 |
[d] | 展示每个方法的详细信息 |
[E] | 开启正则表达式匹配,默认为通配符匹配 |
[c:] | 指定class的 ClassLoader 的 hashcode |
[classLoaderClass:] | 指定执行表达式的 ClassLoader 的 class name |
[n:] | 具有详细信息的匹配类的最大数量(默认为100) |
(1)显示 String 类加载的方法
(2)显示 String 中的 toString 方法详细信息
jad
命令的主要工作是反编译,将 JVM 中实际运行的 class 的 byte code 反编译成 java 代码,便于你理解业务逻辑。
参数名称 | 参数说明 |
---|---|
class-pattern | 类名表达式匹配 |
[c:] | 类所属 ClassLoader 的 hashcode |
[classLoaderClass:] | 指定执行表达式的 ClassLoader 的 class name |
[E] | 开启正则表达式匹配,默认为通配符匹配 |
1 | jad <class-pattern> |
默认情况下,反编译结果里会带有 ClassLoader
信息,通过 --source-only
选项,可以只打印源代码。方便和 mc/redefine 命令结合使用。
1 | jad --source-only <class-pattern> |
1 | jad <class-pattern> <method-pattern> |
mc
是 Memory Compiler 的缩写,编译 .java
文件生成 .class
。
(1)在内存中编译 Hello.java 为 Hello.class
1 | mc /root/Hello.java |
(2)可以通过 -d 命令指定输出目录
1 | mc -d /root/bbb /root/Hello.java |
加载外部的 .class
文件,redefine JVM 已加载的类。
注意, redefine 后的原来的类不能恢复,redefine 有可能失败(比如增加了新的 field),参考 JDK 本身的文档。
参数名称 | 参数说明 |
---|---|
[c:] | ClassLoader的hashcode |
[classLoaderClass:] | 指定执行表达式的 ClassLoader 的 class name |
[p:] | 外部的.class 文件的完整路径,支持多个 |
System.out.println
,只有 run()
函数里的会生效1 | public class MathGame { |
reset
命令对 redefine
的类无效。如果想重置,需要 redefine
原始的字节码。redefine
命令和 jad
/watch
/trace
/monitor
/tt
等命令会冲突。执行完redefine
之后,如果再执行上面提到的命令,则会把 redefine
的字节码重置。 原因是 JDK 本身 redefine 和 Retransform 是不同的机制,同时使用两种机制来更新字节码,只有最后修改的会生效。(1)反编译 MathGame 类
1 | jad demo.MathGame > MathGame.java |
(2)编辑该类,增加两行输出。一行在 main 方法死循环中,一行在 run() 方法首行。
(2)编译修改后的类
1 | mc -d C://Users//Jitwxs//Downloads//MathGame.java C://Users//Jitwxs//Downloads |
原谅我这边没有截图,因为我在 Windows 电脑上执行 mc 命令失败了。
先是提示我 Can not load JavaCompiler from javax.tools.ToolProvider#getSystemJavaCompiler(), please confirm the application running in JDK not JRE。
解决后又报 FileNotFoundException: C:\Users\Jitwxs\Downloads (拒绝访问) 的错。
这就告诉我们,虽然是跨平台的,但还是不要用 Windows 去做命令行开发,否则慢慢踩坑吧。。
(3)加载最新的字节码
1 | redefine C://Users//Jitwxs//Downloads//MathGame.class |
使用 mc
命令来编译 jad
的反编译的代码有可能失败。可以在本地修改代码,编译好后再上传到服务器上。有的服务器不允许直接上传文件,可以使用 base64
命令来绕过。
在本地先转换 .class
文件为 base64,再保存为 result.txt
1 | base64 < Test.class > result.txt |
到服务器上,新建并编辑 result.txt
,复制本地的内容,粘贴再保存
把服务器上的 result.txt
还原为 .class
1 | base64 -d < result.txt > Test.class |
用 MD5 命令计算哈希值,校验是否一致
dump 已加载类的 bytecode 到特定目录。
参数名称 | 参数说明 |
---|---|
class-pattern | 类名表达式匹配 |
[c:] | 类所属 ClassLoader 的 hashcode |
[classLoaderClass:] | 指定执行表达式的 ClassLoader 的 class name |
[d:] | 设置类文件的目标目录 |
[E] | 开启正则表达式匹配,默认为通配符匹配 |
(1)把 String 类的字节码文件保存到当前目录下
1 | dump java.lang.String -d . |
(2)把 demo 包下所有的类的字节码文件保存到当前目录下
1 | dump demo.* -d . |
classloader
命令将 JVM 中所有的classloader的信息统计出来,并可以展示继承树,urls等。
可以让指定的 classloader 去 getResources,打印出所有查找到的 resources 的 url。对于 ResourceNotFoundException
比较有用。
参数名称 | 参数说明 |
---|---|
[l] | 按类加载实例进行统计 |
[t] | 打印所有ClassLoader的继承树 |
[a] | 列出所有ClassLoader加载的类,请谨慎使用 |
[c:] | ClassLoader的hashcode |
[classLoaderClass:] | 指定执行表达式的 ClassLoader 的 class name |
[c: r:] | 用ClassLoader去查找resource |
[c: load:] | 用ClassLoader去加载指定的类 |
(1)按类加载器的类型查看统计信息
1 | classloadaer |
(2)按类加载实例查看统计信息
1 | classloadaer -l |
(3)查看 ClassLoader 的继承树
1 | classloadaer -t |
(4)通过类加载器的 hash,查看此类加载器实际所在的位置
注意hashcode是变化的,需要先查看当前的ClassLoader信息,提取对应ClassLoader的hashcode。对于只有唯一实例的 ClassLoader 可以通过 class name 指定,使用起来更加方便
1 | classloader -c 1c1582d6 |
(5)使用 ClassLoader 去查找指定资源 resource 所在的位置
1 | classloader -c 1c1582d6 -r META-INF/MANIFEST.MF |
(6)使用 ClassLoader 去加载类
1 | classloader -c 5c647e05 --load demo.MathGame |
在本章节中,将学习以下 Arthas 的 JVM 相关命令,同时我也会附上官方文档的链接,方便大家查阅:
显示当前系统的实时数据面板,按 q
或 ctrl+c
退出。
列名 | 描述 |
---|---|
ID | Java 级别的线程ID,注意这个 ID 不能跟 jstack 中的 nativeID 一一对应 |
NAME | 线程名 |
GROUP | 线程组名 |
PRIORITY | 线程优先级, 1~10之间的数字,越大表示优先级越高 |
STATE | 线程的状态 |
%CPU | 线程消耗的 CPU 占比,采样 100ms,将所有线程在这 100ms 内的 CPU 使用量求和,再算出每个线程的 CPU 使用占比。 |
DELTA_TIME | 上次采样之后线程运行增量CPU时间,数据格式为秒 |
TIME | 线程运行总时间,数据格式为分:秒 |
INTERRUPTED | 线程当前的中断位状态 |
DAEMON | 是否是守护线程 |
在使用 dastboard
或者 thread
命令时,会看到有些线程的 ID 为 -1。这是在 Java 8 之后支持观测 JVM 的内部线程。
这些内部线程只有名称和 CPU 时间,没有 ID 及状态等信息。 通过内部线程可以观测到 JVM 活动,如 GC、JIT 编译等占用 CPU 情况,方便了解 JVM 整体运行状况。
JVM内部线程包括下面几种:
C1 CompilerThread0
, C2 CompilerThread0
等GC Thread0
, G1 Young RemSet Sampling
等VM Periodic Task Thread
, VM Thread
, Service Thread
等当 JVM 堆(heap)/元数据(metaspace)空间不足或 OOM 时,可以看到 GC 线程的 CPU 占用率明显高于其他的线程。
当执行 trace/watch/tt/redefine
等命令后,可以看到 JIT 线程活动变得更频繁。因为 JVM 热更新 class 字节码时清除了此 class 相关的 JIT 编译结果,需要重新编译。
参数名称 | 参数说明 |
---|---|
id | 线程 ID |
[n:] | 指定最忙的前 N 个线程并打印堆栈 |
[b] | 找出当前阻塞其他线程的线程 |
[i <value> ] | 指定 CPU 使用率统计的采样间隔,单位为毫秒,默认值为 200 |
[–all] | 显示所有匹配的线程 |
这里的 CPU 使用率与 Linux 命令 top -H -p <pid>
的线程 %CPU
类似,一段采样间隔时间内,当前 JVM 里各个线程的增量 CPU 时间与采样间隔时间的比例。
工作原理如下:
(1)首先第一次采样,获取所有线程的CPU时间(调用的是java.lang.management.ThreadMXBean#getThreadCpuTime()
及sun.management.HotspotThreadMBean.getInternalThreadCpuTimes()
接口)
(2)然后睡眠等待一个间隔时间(默认为 200ms,可以通过 -i
指定间隔时间)
(3)再次第二次采样,获取所有线程的 CPU 时间,对比两次采样数据,计算出每个线程的增量CPU 时间
(4)计算得到 CPU 使用率
线程 CPU 使用率 = 线程增量 CPU 时间 / 采样间隔时间 * 100%
注意: 这个统计也会产生一定的开销(JDK 这个接口本身开销比较大),因此会看到 as 的线程占用一定的百分比,为了降低统计自身的开销带来的影响,可以把采样间隔拉长一些,比如 5000 毫秒。
1 | thread -n 3 |
如果没有进程 ID,且包含[Internal]
表示这是 JVM 内部线程,和 dashboard 命令中相同。
cpuUsage
为采样间隔时间内线程的 CPU 使用率,和 dashboard 命令中相同。
deltaTime
为采样间隔时间内线程的增量 CPU 时间,小于 1ms 时被取整显示为 0ms。
time
线程运行总CPU时间。
默认按照 CPU 增量时间降序排列,只显示第一页数据。
1 | thread |
有时需要获取全部JVM的线程数据进行分析,可以一次全部展示。
1 | thread -all |
1 | thread <pid> |
有时候我们发现应用卡住了, 通常是由于某个线程拿住了某个锁, 并且其他线程都在等待这把锁造成的(即死锁),使用该命令可以一键找出。
1 | thread -b |
注意:3.4.5 版本目前只支持找出synchronized关键字阻塞住的线程, 如果是
java.util.concurrent.Lock
, 目前还不支持。
thread -i 1000
: 统计最近 1000ms 内的线程 CPU 时间thread -n 3 -i 1000
: 列出 1000ms 内最忙的 3 个线程栈1 | thread –state <state> |
查看当前 JVM 的信息
字段 | 含义 |
---|---|
COUNT | JVM 当前活跃的线程数 |
DAEMON-COUNT | JVM 当前活跃的守护线程数 |
PEAK-COUNT | 从 JVM 启动开始曾经活着的最大线程数 |
STARTED-COUNT | 从 JVM 启动开始总共启动过的线程次数 |
DEADLOCK-COUNT | JVM 当前死锁的线程数 |
字段 | 含义 |
---|---|
MAX-FILE-DESCRIPTOR-COUNT | JVM 进程最大可以打开的文件描述符数 |
OPEN-FILE-DESCRIPTOR-COUNT | JVM 当前打开的文件描述符数 |
即 System Property,查看和修改 JVM 的系统属性。
(1)查看所有属性
1 | sysprop |
(2)查看单个属性(支持自动补全)
1 | sysprop <key> |
(3)修改单个属性
1 | sysprop <key> <value> |
即 System Environment Variables,查看当前 JVM 的环境属性。
(1)查看所有环境变量
1 | sysenv |
(2)查看单个环境变量(支持自动补全)
1 | sysenv <key> |
查看、更新 VM 诊断相关的参数。
(1)查看所有选项
1 | vmoption |
(2)查看单个选项(支持自动补全)
1 | vmoption <key> |
3)修改单个选项
1 | vmoption <key> <value> |
推荐直接使用 ognl 命令,更加灵活
通过 getstatic 命令可以方便的查看类的静态属性。
1 | getstatic <class_name> <field_name> |
自 3.0.5 起,Arthas 支持执行 OGNL 表达式。OGNL 的语法需要我们额外学习,点击这里查看详细文档。
参数名称 | 参数说明 |
---|---|
express | 执行的表达式 |
[c:] | 执行表达式的 ClassLoader 的 hashcode,默认值是SystemClassLoader |
[classLoaderClass:] | 指定执行表达式的 ClassLoader 的 class name |
[x] | 结果对象的展开层次,默认值1 |
(1)调用静态函数
1 | ognl '@java.lang.System@out.println("hello")' |
(2)调用静态函数
1 | ognl '@demo.MathGame@random' |
(3)执行多行表达式,赋值给临时变量,返回一个List
1 | ognl '#value1=@System@getProperty("java.home"), #value2=@System@getProperty("java.runtime.name"), {#value1, #value2}' |
在本章节中,将学习以下 Arthas 的基础命令,同时我也会附上官方文档的链接,方便大家查阅:
(1)help
查看命令帮助信息。
(2)cls
清空当前屏幕区域。
(3)session
查看当前会话的信息。
1 | session |
(4)version
输出当前目标 Java 进程所加载的 Arthas 版本号。
1 | version |
(5)history
打印命令历史。
(6)quit
退出当前 Arthas 客户端,其他 Arthas 客户端不受影响。
(7)stop
关闭 Arthas 服务端,所有 Arthas 客户端全部退出。
1 | stop |
打印文件内容,类似于 Linux 中的 cat
命令。
1 | cat c:/Users/Jitwxs/Downloads/helloworld.txt |
匹配查找文件内容,类似于 Linux 中的 grep
命令,但它仅能用于管道命令。
参数列表 | 作用 |
---|---|
-n | 显示行号 |
-i | 忽略大小写查找 |
-m 行数 | 最大显示行数,要与查询字符串一起使用 |
-e “正则表达式” | 使用正则表达式查找 |
(1)只显示包含java字符串的行系统属性。
1 | sysprop | grep java |
(2)显示包含java字符串的行和行号的系统属性。
1 | sysprop | grep java -n |
(3)显示包含system字符串的10行信息。
(4)使用正则表达式,显示包含2个o字符的线程信息。
返回当前的工作目录,类似于 Linux 中的 pwd
命令。
1 | pwd |
重置增强类,将被 Arthas 增强过的类全部还原,Arthas 服务端关闭时会重置所有增强过的类。
(1)还原 Test 类
1 | reset Test |
(2)还原所有以 List 结尾的类
1 | reset *List |
(3)还原所有的类
1 | reset |
查看 Arthas 快捷键列表及自定义快捷键。
tab
键,会根据当前的输入给出提示-
或 --
,然后按 tab
键,可以展示出此命令具体的选项快捷键说明 | 命令说明 |
---|---|
ctrl + a | 跳到行首 |
ctrl + e | 跳到行尾 |
ctrl + f | 向前移动一个单词 |
ctrl + b | 向后移动一个单词 |
键盘左方向键 | 光标向前移动一个字符 |
键盘右方向键 | 光标向后移动一个字符 |
键盘下方向键 | 下翻显示下一个命令 |
键盘上方向键 | 上翻显示上一个命令 |
ctrl + h | 向后删除一个字符 |
ctrl + shift + / | 向后删除一个字符 |
ctrl + u | 撤销上一个命令,相当于清空当前行 |
ctrl + d | 删除当前光标所在字符 |
ctrl + k | 删除当前光标到行尾的所有字符 |
ctrl + i | 自动补全,相当于敲TAB |
ctrl + j | 结束当前行,相当于敲回车 |
ctrl + m | 结束当前行,相当于敲回车 |
ctrl + c | 终止当前命令 |
ctrl + z | 挂起当前命令,后续可以 bg/fg 重新支持此命令,或 kill 掉 |
ctrl + a | 回到行首 |
ctrl + e | 回到行尾 |
早就听闻阿里开源的 Arthas
在做 Java 应用诊断上十分牛逼,身边也有很多同事在使用,因此决定开一个坑,自己从零学习下这个工具的使用,本系列使用的版本是当前最新版 3.4.5。
由于 Arthas 经过这么长时间的发展,本身文档、在线教程已经十分健全了,同时还有第三方的 IDEA 插件、许多教学视频去帮助我们入门使用,因此这个系列的文章定位是个人笔记,而并非教程,希望不要误人子弟。
当你遇到以下类似问题而束手无策时,Arthas
可以帮助你解决:
使用 Arthas 需要 JDK 版本在 1.6 以上。
Arthas 本身也是个 Java 进程,得益于 Java 跨平台特性,所以我就直接在 Windows 上安装了。
(1)下载 Arthas 包
1 | curl -O https://arthas.aliyun.com/arthas-boot.jar |
(2)运行 Arthas
1 | java -jar arthas-boot.jar |
需要注意的是运行 Arthas 前至少保证系统正在运行一个 Java 进程,否则无法启动,并会报错:Can not find java process. Try to pass
in command line.Please select an available pid。解决办法就是跑一个 Java 应用即可。
如果需要卸载 Arthas 的话:
在 Linux/Unix/Mac 平台,删除下面文件:
1 | rm -rf ~/.arthas/ |
Windows平台直接删除user home下面的.arthas
和logs/arthas
目录
这里我们使用 Arthas 官方提供的 demo 包,这样我们就不需要自己编写代码了。将 demo 包下载下来并运行。
1 | curl -O https://arthas.aliyun.com/arthas-demo.jar |
这个 demo 功能是死循环做质因数分解,并记录下无法分解的次数,如下图所示。
我们首先启动 Arthas 并 attach 上该进程。
默认情况下,Arthas只listen 127.0.0.1,所以如果想从远程连接,则可以使用
--target-ip
参数指定 listen 的IP
另外如果条件允许的话,在 attach 后也可以使用浏览器登录,访问:http://127.0.0.1:3658 即可。也可以填入 IP,远程连接其他机器的 Arthas。
使用 dastboard
命令可以查看 Java 进程信息(定时刷新),如需退出使用 q
即可。它由如下四个部分组成:
使用 thread
命令可以查看当前所有的线程信息。
并且可以通过追加 PID 的方式,查看具体某个线程的状态。
使用 jad
命令可以反编译 class 文件。
watch
命令可以监控方法的入参出参:
如果只是退出当前的连接,可以用quit
或者exit
命令。Attach到目标进程上的 Arthas 还会继续运行,端口会保持开放,下次连接时可以直接连接上。
如果想完全退出arthas,可以执行stop
命令。
JVM 参数类型主要分为标准参数、X 参数、XX 参数三类。
(1)对于标准参数,在 JVM 的各个版本中基本是不变的,相对是比较稳定的。例如 -help
、-version
、-server
、-client
等。
(2)X 参数是非标准化参数,在不同的 JVM 版本中可能会发生变化。例如:
-Xint
:完全解释执行,不编译成本地代码-Xcomp
:第一次使用就编译成本地代码-Xmixed
:混合模式,JVM自行决定是否编译成本地代码(默认模式)(3)XX 参数是我们使用最为经常的参数,它也是非标准化参数,主要用于 JVM 调优和 Debug。它分为 Boolean 类型和键值对类型。
1 | Boolean 类型 |
1 | 键值对类型 |
各大 JVM 的相关参数可以到这个网站去查询:https://chriswhocodes.com/hotspot_options_jdk8.html
JPS
类似于 Linux 系统中的 PS 命令,只是它是专门用来查看 Java 进程的 PID,点击这里查看官方文档。
使用 jps -l
可以获取 Java 进程的包名,例如我启动了一个 SpringBoot 项目,使用该命令后可以拿到它的 PID 是 3007。
JINFO
命令可以查看 Java 进程的 JVM 参数,点击这里查看官方文档。
1 | jinfo -flag <name> <pid> 查看进程的某一个 JVM 参数 |
1 | jinfo -flags <pid> 查看进程所有非默认的 JVM 参数 |
jstat
可以查看 JVM 的统计信息(类加载、垃圾收集、JIT 编译等),点击这里查看官方文档。
1 | 格式:jstat -class <pid> <interval> <count> 每隔 Interval 毫秒查看一次进程的类加载信息,查看 count 次 |
1 | 格式:jstat -gc <pid> <interval> <count> 每隔 Interval 毫秒查看一次进程的 GC 信息,查看 count 次 |
除了列出的 -gc 外,还有 -gccapactly、-gccause、-gcnew、-gcold 等
1 | 格式:jstat -compiler <pid> 查看进程 JIT 编译信息 |
jmap
命令可以帮助我们获取 Java 进程的内存快照,并可以将其导入到 MAT(免费)或 JProfile(付费)等工具中去做内存分析。这边的内容我在 《首次排查 OOM 实录》 这篇文章中提到了,就不再赘述了。
jstack
命令可以帮助我们获取 Java 进程中的线程快照,点击这里查看官方文档。
进程状态一般有以下几种(https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr034.html):
Thread State | Description |
---|---|
NEW | 线程未启动 |
RUNNABLE | 线程已在 JVM 中运行 |
BLOCKED | 线程处于阻塞状态等待锁 |
WAITING | 线程无限期地等待另一个线程执行特定操作 |
TIMED_WAITING | WAITING 状态加上超时时间 |
TERMINATED | 线程已经退出 |
例如当我们发现 CPU 的利用率飙高,就需要使用 jstack 查看下线程的状态了,一般的操作流程是这样的:
(1)jps -l
获取到需要分析的 Java 进程的 PID。【本例中为 3007】
(2)top -Hp <PID>
查看该 Java 进程内部每个线程的 CPU 和占用情况,找到你觉得有问题的那一个线程的 PID,转成十六进制记录下来。【我这个程序只是个 helloword,所以我就随便找一个线程了。比如取 3018,它的十六进制是 0xbca】
(3)jstack <PID> <filename>
获取到线程快照。【本例中输出为 a.out】
(4)然后在其中搜索你觉得有问题的那个进程 ID。【例子举得不好,找的线程是 GC 线程,知道是这么个流程就行】
]]>