From c5cae282613d4229a8e4e9b0c3de5a1924d177a9 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Wed, 3 Jul 2019 21:44:06 +0100 Subject: [PATCH] Add support for plain text thread dumps to the threaddump endpoint When a request that accepts text/plain is received, the threaddump endpoint will now return a thread dump in plain text. The format of this text is modelled after the output produced by JVisualVM when connecting to a remote process over JMX. Note that this output does not include all of the information in, for example, JStack's output as it is not available via Java 8's ThreadInfo API. Rather than the custom formatting logic, using ThreadInfo's toString() method was considered but its output is documented as being undefined and implementation specific. The implementation used while developing this feature produced output that did not match that of JStack or JVisualVM and truncated stack traces quite considerably. At the time of writing the format produced by the endpoint could be consumed by both Thread Dump Analyzer [1] and https://fastthread.io. Closes gh-2339 [1] https://github.com/irockel/tda --- .../main/asciidoc/endpoints/threaddump.adoc | 30 ++- .../ThreadDumpEndpointDocumentationTests.java | 230 ++++++++++-------- .../PlainTextThreadDumpFormatter.java | 127 ++++++++++ .../management/ThreadDumpEndpoint.java | 18 +- .../management/ThreadDumpEndpointTests.java | 89 +++++++ ...ThreadDumpEndpointWebIntegrationTests.java | 59 +++++ 6 files changed, 444 insertions(+), 109 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/management/PlainTextThreadDumpFormatter.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/management/ThreadDumpEndpointWebIntegrationTests.java diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/threaddump.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/threaddump.adoc index 997d3f36ec..1c8d4328e6 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/threaddump.adoc +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/threaddump.adoc @@ -5,25 +5,39 @@ The `threaddump` endpoint provides a thread dump from the application's JVM. -[[threaddump-retrieving]] -== Retrieving the Thread Dump +[[threaddump-retrieving-json]] +== Retrieving the Thread Dump as JSON -To retrieve the thread dump, make a `GET` request to `/actuator/threaddump`, as shown -in the following curl-based example: +To retrieve the thread dump as JSON, make a `GET` request to `/actuator/threaddump` with +an appropriate `Accept` header, as shown in the following curl-based example: -include::{snippets}threaddump/curl-request.adoc[] +include::{snippets}threaddump/json/curl-request.adoc[] The resulting response is similar to the following: -include::{snippets}threaddump/http-response.adoc[] +include::{snippets}threaddump/json/http-response.adoc[] -[[threaddump-retrieving-response-structure]] +[[threaddump-retrieving-json-response-structure]] === Response Structure The response contains details of the JVM's threads. The following table describes the structure of the response: [cols="3,1,2"] -include::{snippets}threaddump/response-fields.adoc[] +include::{snippets}threaddump/json/response-fields.adoc[] + + + +[[threaddump-retrieving-text]] +== Retrieving the Thread Dump as Text + +To retrieve the thread dump as text, make a `GET` request to `/actuator/threaddump` that +accepts `text/plain`, as shown in the following curl-based example: + +include::{snippets}threaddump/text/curl-request.adoc[] + +The resulting response is similar to the following: + +include::{snippets}threaddump/text/http-response.adoc[] diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ThreadDumpEndpointDocumentationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ThreadDumpEndpointDocumentationTests.java index d986a546e2..a52d4dce96 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ThreadDumpEndpointDocumentationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ThreadDumpEndpointDocumentationTests.java @@ -16,6 +16,7 @@ package org.springframework.boot.actuate.autoconfigure.endpoint.web.documentation; +import java.nio.charset.StandardCharsets; import java.util.concurrent.CountDownLatch; import java.util.concurrent.locks.ReentrantLock; @@ -25,7 +26,9 @@ import org.springframework.boot.actuate.management.ThreadDumpEndpoint; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.operation.preprocess.ContentModifyingOperationPreprocessor; import org.springframework.restdocs.payload.JsonFieldType; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; @@ -43,7 +46,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. class ThreadDumpEndpointDocumentationTests extends MockMvcEndpointDocumentationTests { @Test - void threadDump() throws Exception { + void jsonThreadDump() throws Exception { ReentrantLock lock = new ReentrantLock(); CountDownLatch latch = new CountDownLatch(1); new Thread(() -> { @@ -60,106 +63,137 @@ class ThreadDumpEndpointDocumentationTests extends MockMvcEndpointDocumentationT Thread.currentThread().interrupt(); } }).start(); - this.mockMvc.perform(get("/actuator/threaddump")).andExpect(status().isOk()) - .andDo(MockMvcRestDocumentation.document("threaddump", preprocessResponse(limit("threads")), - responseFields(fieldWithPath("threads").description("JVM's threads."), - fieldWithPath("threads.[].blockedCount") - .description("Total number of times that the thread has been " + "blocked."), - fieldWithPath("threads.[].blockedTime") - .description("Time in milliseconds that the thread has spent " - + "blocked. -1 if thread contention " + "monitoring is disabled."), - fieldWithPath("threads.[].daemon") - .description("Whether the thread is a daemon " - + "thread. Only available on Java 9 or " + "later.") - .optional().type(JsonFieldType.BOOLEAN), - fieldWithPath("threads.[].inNative") - .description("Whether the thread is executing native code."), - fieldWithPath("threads.[].lockName").description( - "Description of the object on which the " + "thread is blocked, if any.") - .optional().type(JsonFieldType.STRING), - fieldWithPath("threads.[].lockInfo") - .description("Object for which the thread is blocked " + "waiting.").optional() - .type(JsonFieldType.OBJECT), - fieldWithPath("threads.[].lockInfo.className") - .description("Fully qualified class name of the lock" + " object.").optional() - .type(JsonFieldType.STRING), - fieldWithPath("threads.[].lockInfo.identityHashCode") - .description("Identity hash code of the lock object.").optional() - .type(JsonFieldType.NUMBER), - fieldWithPath("threads.[].lockedMonitors") - .description("Monitors locked by this thread, if any"), - fieldWithPath("threads.[].lockedMonitors.[].className") - .description("Class name of the lock object.").optional() - .type(JsonFieldType.STRING), - fieldWithPath("threads.[].lockedMonitors.[].identityHashCode") - .description("Identity hash code of the lock " + "object.").optional() - .type(JsonFieldType.NUMBER), - fieldWithPath("threads.[].lockedMonitors.[].lockedStackDepth") - .description("Stack depth where the monitor " + "was locked.").optional() - .type(JsonFieldType.NUMBER), - subsectionWithPath("threads.[].lockedMonitors.[].lockedStackFrame") - .description("Stack frame that locked the " + "monitor.").optional() - .type(JsonFieldType.OBJECT), - fieldWithPath("threads.[].lockedSynchronizers") - .description("Synchronizers locked by this thread."), - fieldWithPath("threads.[].lockedSynchronizers.[].className").description( - "Class name of the locked " + "synchronizer.").optional() - .type(JsonFieldType.STRING), - fieldWithPath("threads.[].lockedSynchronizers.[].identityHashCode").description( - "Identity hash code of the locked " + "synchronizer.").optional() - .type(JsonFieldType.NUMBER), - fieldWithPath("threads.[].lockOwnerId").description( - "ID of the thread that owns the object on which " - + "the thread is blocked. `-1` if the " + "thread is not blocked."), - fieldWithPath("threads.[].lockOwnerName") - .description("Name of the thread that owns the " - + "object on which the thread is " + "blocked, if any.") - .optional().type(JsonFieldType.STRING), - fieldWithPath("threads.[].priority") - .description("Priority of the thread. Only " + "available on Java 9 or later.") - .optional().type(JsonFieldType.NUMBER), - fieldWithPath("threads.[].stackTrace").description("Stack trace of the thread."), - fieldWithPath("threads.[].stackTrace.[].classLoaderName").description( - "Name of the class loader of the " + "class that contains the execution " - + "point identified by this entry, if " - + "any. Only available on Java 9 or " + "later.") - .optional().type(JsonFieldType.STRING), - fieldWithPath("threads.[].stackTrace.[].className").description( - "Name of the class that contains the " + "execution point identified " - + "by this entry."), - fieldWithPath("threads.[].stackTrace.[].fileName") - .description("Name of the source file that " + "contains the execution point " - + "identified by this entry, if any.") - .optional().type(JsonFieldType.STRING), - fieldWithPath("threads.[].stackTrace.[].lineNumber") - .description("Line number of the execution " - + "point identified by this entry. " + "Negative if unknown."), - fieldWithPath("threads.[].stackTrace.[].methodName").description("Name of the method."), - fieldWithPath("threads.[].stackTrace.[].moduleName") - .description("Name of the module that contains " - + "the execution point identified by " - + "this entry, if any. Only available " + "on Java 9 or later.") - .optional().type(JsonFieldType.STRING), - fieldWithPath("threads.[].stackTrace.[].moduleVersion") - .description("Version of the module that " + "contains the execution point " - + "identified by this entry, if any. " - + "Only available on Java 9 or later.") - .optional().type(JsonFieldType.STRING), - fieldWithPath("threads.[].stackTrace.[].nativeMethod") - .description("Whether the execution point is a native " + "method."), - fieldWithPath("threads.[].suspended").description("Whether the thread is suspended."), - fieldWithPath("threads.[].threadId").description("ID of the thread."), - fieldWithPath("threads.[].threadName").description("Name of the thread."), - fieldWithPath("threads.[].threadState").description( - "State of the thread (" + describeEnumValues(Thread.State.class) + ")."), - fieldWithPath("threads.[].waitedCount").description( - "Total number of times that the thread has waited" + " for notification."), - fieldWithPath("threads.[].waitedTime") - .description("Time in milliseconds that the thread has spent " - + "waiting. -1 if thread contention " + "monitoring is disabled")))); + this.mockMvc.perform(get("/actuator/threaddump").accept(MediaType.APPLICATION_JSON)).andExpect( + status().isOk()).andDo( + MockMvcRestDocumentation + .document("threaddump/json", preprocessResponse(limit("threads")), + responseFields(fieldWithPath("threads").description("JVM's threads."), + fieldWithPath("threads.[].blockedCount").description( + "Total number of times that the thread has been " + "blocked."), + fieldWithPath("threads.[].blockedTime").description( + "Time in milliseconds that the thread has spent " + + "blocked. -1 if thread contention " + + "monitoring is disabled."), + fieldWithPath("threads.[].daemon") + .description("Whether the thread is a daemon " + + "thread. Only available on Java 9 or " + "later.") + .optional().type(JsonFieldType.BOOLEAN), + fieldWithPath("threads.[].inNative") + .description("Whether the thread is executing native code."), + fieldWithPath("threads.[].lockName") + .description("Description of the object on which the " + + "thread is blocked, if any.") + .optional().type(JsonFieldType.STRING), + fieldWithPath("threads.[].lockInfo") + .description( + "Object for which the thread is blocked " + "waiting.") + .optional().type(JsonFieldType.OBJECT), + fieldWithPath("threads.[].lockInfo.className") + .description( + "Fully qualified class name of the lock" + " object.") + .optional().type(JsonFieldType.STRING), + fieldWithPath("threads.[].lockInfo.identityHashCode") + .description("Identity hash code of the lock object.") + .optional().type(JsonFieldType.NUMBER), + fieldWithPath("threads.[].lockedMonitors") + .description("Monitors locked by this thread, if any"), + fieldWithPath("threads.[].lockedMonitors.[].className") + .description("Class name of the lock object.").optional() + .type(JsonFieldType.STRING), + fieldWithPath("threads.[].lockedMonitors.[].identityHashCode") + .description("Identity hash code of the lock " + "object.") + .optional().type(JsonFieldType.NUMBER), + fieldWithPath("threads.[].lockedMonitors.[].lockedStackDepth") + .description("Stack depth where the monitor " + "was locked.") + .optional().type(JsonFieldType.NUMBER), + subsectionWithPath("threads.[].lockedMonitors.[].lockedStackFrame") + .description("Stack frame that locked the " + "monitor.") + .optional().type(JsonFieldType.OBJECT), + fieldWithPath("threads.[].lockedSynchronizers") + .description("Synchronizers locked by this thread."), + fieldWithPath("threads.[].lockedSynchronizers.[].className") + .description("Class name of the locked " + "synchronizer.") + .optional().type(JsonFieldType.STRING), + fieldWithPath("threads.[].lockedSynchronizers.[].identityHashCode") + .description( + "Identity hash code of the locked " + "synchronizer.") + .optional().type(JsonFieldType.NUMBER), + fieldWithPath("threads.[].lockOwnerId") + .description("ID of the thread that owns the object on which " + + "the thread is blocked. `-1` if the " + + "thread is not blocked."), + fieldWithPath("threads.[].lockOwnerName") + .description("Name of the thread that owns the " + + "object on which the thread is " + "blocked, if any.") + .optional().type(JsonFieldType.STRING), + fieldWithPath("threads.[].priority") + .description("Priority of the thread. Only " + + "available on Java 9 or later.") + .optional().type(JsonFieldType.NUMBER), + fieldWithPath("threads.[].stackTrace") + .description("Stack trace of the thread."), + fieldWithPath("threads.[].stackTrace.[].classLoaderName") + .description("Name of the class loader of the " + + "class that contains the execution " + + "point identified by this entry, if " + + "any. Only available on Java 9 or " + "later.") + .optional().type(JsonFieldType.STRING), + fieldWithPath("threads.[].stackTrace.[].className") + .description("Name of the class that contains the " + + "execution point identified " + "by this entry."), + fieldWithPath("threads.[].stackTrace.[].fileName") + .description("Name of the source file that " + + "contains the execution point " + + "identified by this entry, if any.") + .optional().type(JsonFieldType.STRING), + fieldWithPath("threads.[].stackTrace.[].lineNumber") + .description("Line number of the execution " + + "point identified by this entry. " + + "Negative if unknown."), + fieldWithPath("threads.[].stackTrace.[].methodName") + .description("Name of the method."), + fieldWithPath("threads.[].stackTrace.[].moduleName") + .description("Name of the module that contains " + + "the execution point identified by " + + "this entry, if any. Only available " + + "on Java 9 or later.") + .optional().type(JsonFieldType.STRING), + fieldWithPath("threads.[].stackTrace.[].moduleVersion") + .description("Version of the module that " + + "contains the execution point " + + "identified by this entry, if any. " + + "Only available on Java 9 or later.") + .optional().type(JsonFieldType.STRING), + fieldWithPath("threads.[].stackTrace.[].nativeMethod").description( + "Whether the execution point is a native " + "method."), + fieldWithPath("threads.[].suspended") + .description("Whether the thread is suspended."), + fieldWithPath("threads.[].threadId").description("ID of the thread."), + fieldWithPath("threads.[].threadName") + .description("Name of the thread."), + fieldWithPath("threads.[].threadState") + .description("State of the thread (" + + describeEnumValues(Thread.State.class) + ")."), + fieldWithPath("threads.[].waitedCount") + .description("Total number of times that the thread has waited" + + " for notification."), + fieldWithPath("threads.[].waitedTime") + .description("Time in milliseconds that the thread has spent " + + "waiting. -1 if thread contention " + + "monitoring is disabled")))); latch.countDown(); } + @Test + void textThreadDump() throws Exception { + this.mockMvc.perform(get("/actuator/threaddump").accept(MediaType.TEXT_PLAIN)).andExpect(status().isOk()) + .andDo(MockMvcRestDocumentation.document("threaddump/text", + preprocessResponse(new ContentModifyingOperationPreprocessor((bytes, mediaType) -> { + String content = new String(bytes, StandardCharsets.UTF_8); + return content.substring(0, content.indexOf("\"main\" - Thread")).getBytes(); + })))); + } + @Configuration(proxyBeanMethods = false) @Import(BaseDocumentationConfiguration.class) static class TestConfiguration { diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/management/PlainTextThreadDumpFormatter.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/management/PlainTextThreadDumpFormatter.java new file mode 100644 index 0000000000..96c64b4e18 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/management/PlainTextThreadDumpFormatter.java @@ -0,0 +1,127 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.management; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.management.LockInfo; +import java.lang.management.ManagementFactory; +import java.lang.management.MonitorInfo; +import java.lang.management.RuntimeMXBean; +import java.lang.management.ThreadInfo; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Formats a thread dump as plain text. + * + * @author Andy Wilkinson + */ +class PlainTextThreadDumpFormatter { + + String format(ThreadInfo[] threads) { + StringWriter dump = new StringWriter(); + PrintWriter writer = new PrintWriter(dump); + writePreamble(writer); + for (ThreadInfo info : threads) { + writeThread(writer, info); + } + return dump.toString(); + } + + private void writePreamble(PrintWriter writer) { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + writer.println(dateFormat.format(new Date())); + RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean(); + writer.printf("Full thread dump %s (%s %s):%n", runtime.getVmName(), runtime.getVmVersion(), + System.getProperty("java.vm.info")); + writer.println(); + } + + private void writeThread(PrintWriter writer, ThreadInfo info) { + writer.printf("\"%s\" - Thread t@%d%n", info.getThreadName(), info.getThreadId()); + writer.printf(" %s: %s%n", Thread.State.class.getCanonicalName(), info.getThreadState()); + writeStackTrace(writer, info, info.getLockedMonitors()); + writer.println(); + writeLockedOwnableSynchronizers(writer, info); + writer.println(); + } + + private void writeStackTrace(PrintWriter writer, ThreadInfo info, MonitorInfo[] lockedMonitors) { + int depth = 0; + for (StackTraceElement element : info.getStackTrace()) { + writeStackTraceElement(writer, element, info, lockedMonitorsForDepth(lockedMonitors, depth), depth == 0); + depth++; + } + } + + private List lockedMonitorsForDepth(MonitorInfo[] lockedMonitors, int depth) { + return Stream.of(lockedMonitors).filter((lockedMonitor) -> lockedMonitor.getLockedStackDepth() == depth) + .collect(Collectors.toList()); + } + + private void writeStackTraceElement(PrintWriter writer, StackTraceElement element, ThreadInfo info, + List lockedMonitors, boolean firstElement) { + writer.printf("\tat %s%n", element.toString()); + LockInfo lockInfo = info.getLockInfo(); + if (firstElement && lockInfo != null) { + if (element.getClassName().equals(Object.class.getName()) && element.getMethodName().equals("wait")) { + if (lockInfo != null) { + writer.printf("\t- waiting on %s%n", format(lockInfo)); + } + } + else { + String lockOwner = info.getLockOwnerName(); + if (lockOwner != null) { + writer.printf("\t- waiting to lock %s owned by \"%s\" t@%d%n", format(lockInfo), lockOwner, + info.getLockOwnerId()); + } + else { + writer.printf("\t- parking to wait for %s%n", format(lockInfo)); + } + } + } + writeMonitors(writer, lockedMonitors); + } + + private String format(LockInfo lockInfo) { + return String.format("<%x> (a %s)", lockInfo.getIdentityHashCode(), lockInfo.getClassName()); + } + + private void writeMonitors(PrintWriter writer, List lockedMonitorsAtCurrentDepth) { + for (MonitorInfo lockedMonitor : lockedMonitorsAtCurrentDepth) { + writer.printf("\t- locked %s%n", format(lockedMonitor)); + } + } + + private void writeLockedOwnableSynchronizers(PrintWriter writer, ThreadInfo info) { + writer.println(" Locked ownable synchronizers:"); + LockInfo[] lockedSynchronizers = info.getLockedSynchronizers(); + if (lockedSynchronizers == null || lockedSynchronizers.length == 0) { + writer.println("\t- None"); + } + else { + for (LockInfo lockedSynchronizer : lockedSynchronizers) { + writer.printf("\t- Locked %s%n", format(lockedSynchronizer)); + } + } + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/management/ThreadDumpEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/management/ThreadDumpEndpoint.java index 4e65ccd1cf..831c528af1 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/management/ThreadDumpEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/management/ThreadDumpEndpoint.java @@ -20,6 +20,7 @@ import java.lang.management.ManagementFactory; import java.lang.management.ThreadInfo; import java.util.Arrays; import java.util.List; +import java.util.function.Function; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; @@ -34,9 +35,20 @@ import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; @Endpoint(id = "threaddump") public class ThreadDumpEndpoint { + private final PlainTextThreadDumpFormatter plainTextFormatter = new PlainTextThreadDumpFormatter(); + @ReadOperation public ThreadDumpDescriptor threadDump() { - return new ThreadDumpDescriptor(Arrays.asList(ManagementFactory.getThreadMXBean().dumpAllThreads(true, true))); + return getFormattedThreadDump(ThreadDumpDescriptor::new); + } + + @ReadOperation(produces = "text/plain;charset=UTF-8") + public String textThreadDump() { + return getFormattedThreadDump(this.plainTextFormatter::format); + } + + private T getFormattedThreadDump(Function formatter) { + return formatter.apply(ManagementFactory.getThreadMXBean().dumpAllThreads(true, true)); } /** @@ -46,8 +58,8 @@ public class ThreadDumpEndpoint { private final List threads; - private ThreadDumpDescriptor(List threads) { - this.threads = threads; + private ThreadDumpDescriptor(ThreadInfo[] threads) { + this.threads = Arrays.asList(threads); } public List getThreads() { diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/management/ThreadDumpEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/management/ThreadDumpEndpointTests.java index 9ba758d32d..e1d4d23570 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/management/ThreadDumpEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/management/ThreadDumpEndpointTests.java @@ -16,6 +16,12 @@ package org.springframework.boot.actuate.management; +import java.lang.Thread.State; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -33,4 +39,87 @@ class ThreadDumpEndpointTests { assertThat(new ThreadDumpEndpoint().threadDump().getThreads().size()).isGreaterThan(0); } + @Test + void dumpThreadsAsText() throws InterruptedException { + Object contendedMonitor = new Object(); + Object monitor = new Object(); + CountDownLatch latch = new CountDownLatch(1); + Thread awaitCountDownLatchThread = new Thread(() -> { + try { + latch.await(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + }, "Awaiting CountDownLatch"); + awaitCountDownLatchThread.start(); + Thread contendedMonitorThread = new Thread(() -> { + synchronized (contendedMonitor) { + // Intentionally empty + } + }, "Waiting for monitor"); + Thread waitOnMonitorThread = new Thread(() -> { + synchronized (monitor) { + try { + monitor.wait(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + }, "Waiting on monitor"); + waitOnMonitorThread.start(); + ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); + Lock writeLock = readWriteLock.writeLock(); + new Thread(() -> { + writeLock.lock(); + try { + latch.await(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + finally { + writeLock.unlock(); + } + }, "Holding write lock").start(); + while (writeLock.tryLock()) { + writeLock.unlock(); + } + awaitState(waitOnMonitorThread, State.WAITING); + awaitState(awaitCountDownLatchThread, State.WAITING); + String threadDump; + synchronized (contendedMonitor) { + contendedMonitorThread.start(); + awaitState(contendedMonitorThread, State.BLOCKED); + threadDump = new ThreadDumpEndpoint().textThreadDump(); + } + latch.countDown(); + synchronized (monitor) { + monitor.notifyAll(); + } + System.out.println(threadDump); + assertThat(threadDump) + .containsPattern(String.format("\t- parking to wait for <[0-9a-z]+> \\(a %s\\$Sync\\)", + CountDownLatch.class.getName().replace(".", "\\."))) + .contains(String.format("\t- locked <%s> (a java.lang.Object)", hexIdentityHashCode(contendedMonitor))) + .contains(String.format("\t- waiting to lock <%s> (a java.lang.Object) owned by \"%s\" t@%d", + hexIdentityHashCode(contendedMonitor), Thread.currentThread().getName(), + Thread.currentThread().getId())) + .contains(String.format("\t- waiting on <%s> (a java.lang.Object)", hexIdentityHashCode(monitor))) + .containsPattern( + String.format("Locked ownable synchronizers:%n\t- Locked <[0-9a-z]+> \\(a %s\\$NonfairSync\\)", + ReentrantReadWriteLock.class.getName().replace(".", "\\."))); + } + + private String hexIdentityHashCode(Object object) { + return Integer.toHexString(System.identityHashCode(object)); + } + + private void awaitState(Thread thread, State state) throws InterruptedException { + while (thread.getState() != state) { + Thread.sleep(50); + } + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/management/ThreadDumpEndpointWebIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/management/ThreadDumpEndpointWebIntegrationTests.java new file mode 100644 index 0000000000..077aa0418c --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/management/ThreadDumpEndpointWebIntegrationTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.management; + +import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link ThreadDumpEndpoint} exposed by Jersey, Spring MVC, and + * WebFlux. + * + * @author Andy Wilkinson + */ +class ThreadDumpEndpointWebIntegrationTests { + + @WebEndpointTest + void getRequestWithJsonAcceptHeaderShouldProduceJsonThreadDumpResponse(WebTestClient client) throws Exception { + client.get().uri("/actuator/threaddump").accept(MediaType.APPLICATION_JSON).exchange().expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON); + } + + @WebEndpointTest + void getRequestWithTextPlainAcceptHeaderShouldProduceTextPlainResponse(WebTestClient client) throws Exception { + String response = client.get().uri("/actuator/threaddump").accept(MediaType.TEXT_PLAIN).exchange() + .expectStatus().isOk().expectHeader().contentType("text/plain;charset=UTF-8").expectBody(String.class) + .returnResult().getResponseBody(); + assertThat(response).contains("Full thread dump"); + } + + @Configuration(proxyBeanMethods = false) + public static class TestConfiguration { + + @Bean + public ThreadDumpEndpoint endpoint() { + return new ThreadDumpEndpoint(); + } + + } + +}