diff --git a/benchmarks/benchmarks-core/src/jmh/java/io/micrometer/benchmark/core/MeterRegistrationBenchmark.java b/benchmarks/benchmarks-core/src/jmh/java/io/micrometer/benchmark/core/MeterRegistrationBenchmark.java index ea9d5a1dc8..467598ce3a 100644 --- a/benchmarks/benchmarks-core/src/jmh/java/io/micrometer/benchmark/core/MeterRegistrationBenchmark.java +++ b/benchmarks/benchmarks-core/src/jmh/java/io/micrometer/benchmark/core/MeterRegistrationBenchmark.java @@ -33,7 +33,7 @@ @Warmup(iterations = 2) @Measurement(iterations = 5) @BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(TimeUnit.MICROSECONDS) +@OutputTimeUnit(TimeUnit.NANOSECONDS) @Threads(2) public class MeterRegistrationBenchmark { @@ -49,7 +49,7 @@ public static void main(String[] args) throws RunnerException { Meter.MeterProvider counterMeterProvider = Counter.builder("jmh.existing").withRegistry(registry); - Tags tags = Tags.of("key", "value"); + Tags tags = Tags.of("k1", "v1"); @Setup public void setup() { @@ -89,8 +89,8 @@ public Meter registerStale() { } @Benchmark - public Meter registerExisting() { - return registry.counter("jmh.existing", "k1", "v1"); + public Meter registerExistingCounter() { + return registry.counter("jmh.existing", tags); } @Benchmark diff --git a/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/types/DynatraceTimerTest.java b/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/types/DynatraceTimerTest.java index 34c28e400f..472e6b5c5e 100644 --- a/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/types/DynatraceTimerTest.java +++ b/implementations/micrometer-registry-dynatrace/src/test/java/io/micrometer/dynatrace/types/DynatraceTimerTest.java @@ -47,7 +47,7 @@ class DynatraceTimerTest { private static final DistributionStatisticConfig DISTRIBUTION_STATISTIC_CONFIG = DistributionStatisticConfig.NONE; - private static final PauseDetector PAUSE_DETECTOR = new NoPauseDetector(); + private static final PauseDetector PAUSE_DETECTOR = NoPauseDetector.INSTANCE; @Test void testTimerCount() { diff --git a/micrometer-core/src/main/java/io/micrometer/core/instrument/MeterRegistry.java b/micrometer-core/src/main/java/io/micrometer/core/instrument/MeterRegistry.java index 1b316de62b..ed18d0228d 100644 --- a/micrometer-core/src/main/java/io/micrometer/core/instrument/MeterRegistry.java +++ b/micrometer-core/src/main/java/io/micrometer/core/instrument/MeterRegistry.java @@ -31,6 +31,8 @@ import io.micrometer.core.instrument.search.RequiredSearch; import io.micrometer.core.instrument.search.Search; import io.micrometer.core.instrument.util.TimeUtils; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullUnmarked; import org.jspecify.annotations.Nullable; import java.time.Duration; @@ -106,7 +108,7 @@ public abstract class MeterRegistry { /** * write/remove guarded by meterMapLock, read in - * {@link #getOrCreateMeter(DistributionStatisticConfig, BiFunction, Meter.Id, Function)} + * {@link #getOrCreateMeter(DistributionStatisticConfig, PauseDetector, NewMeterSupplier, Meter.Id, Function)} * is unguarded */ private final Map preFilterIdToMeterMap = new HashMap<>(); @@ -135,7 +137,7 @@ public abstract class MeterRegistry { private final AtomicBoolean closed = new AtomicBoolean(); - private PauseDetector pauseDetector = new NoPauseDetector(); + private PauseDetector pauseDetector = NoPauseDetector.INSTANCE; private @Nullable HighCardinalityTagsDetector highCardinalityTagsDetector; @@ -335,12 +337,15 @@ private String getBaseTimeUnitStr() { } /** - * Only used by {@link Counter#builder(String)}. + * Only used internally. * @param id The identifier for this counter. * @return A new or existing counter. + * @implNote Avoids allocation in the case the meter is already registered, + * particularly capturing lambdas. */ Counter counter(Meter.Id id) { - return registerMeterIfNecessary(Counter.class, id, this::newCounter, NoopCounter::new); + return registerMeterIfNecessary(Counter.class, id, null, null, + (registry, mappedId, mappedConfig, pd) -> registry.newCounter(mappedId), NoopCounter::new); } /** @@ -352,22 +357,27 @@ Counter counter(Meter.Id id) { * @return A new or existing gauge. */ Gauge gauge(Meter.Id id, @Nullable T obj, ToDoubleFunction valueFunction) { - return registerMeterIfNecessary(Gauge.class, id, id2 -> newGauge(id2, obj, valueFunction), NoopGauge::new); + return registerMeterIfNecessary(Gauge.class, id, + (registry, mappedId) -> registry.newGauge(mappedId, obj, valueFunction), NoopGauge::new); } /** - * Only used by {@link Timer#builder(String)}. + * Only used internally. * @param id The identifier for this timer. * @param distributionStatisticConfig Configuration that governs how distribution * statistics are computed. + * @param specificPauseDetector explicit pause detector to use that may be different + * from the registry-configured one * @return A new or existing timer. + * @implNote Avoids allocation in the case the meter is already registered, + * particularly capturing lambdas. */ Timer timer(Meter.Id id, DistributionStatisticConfig distributionStatisticConfig, - PauseDetector pauseDetectorOverride) { - return registerMeterIfNecessary(Timer.class, id, distributionStatisticConfig, (id2, filteredConfig) -> { - Meter.Id withUnit = id2.withBaseUnit(getBaseTimeUnitStr()); - return newTimer(withUnit, filteredConfig.merge(defaultHistogramConfig()), pauseDetectorOverride); - }, NoopTimer::new); + PauseDetector specificPauseDetector) { + return registerMeterIfNecessary(Timer.class, id, distributionStatisticConfig, specificPauseDetector, + (registry, mappedId, mappedConfig, pd) -> registry + .newTimer(mappedId.withBaseUnit(registry.getBaseTimeUnitStr()), mappedConfig, pd), + NoopTimer::new); } /** @@ -378,8 +388,9 @@ Timer timer(Meter.Id id, DistributionStatisticConfig distributionStatisticConfig * @return A new or existing distribution summary. */ DistributionSummary summary(Meter.Id id, DistributionStatisticConfig distributionStatisticConfig, double scale) { - return registerMeterIfNecessary(DistributionSummary.class, id, distributionStatisticConfig, (id2, - filteredConfig) -> newDistributionSummary(id2, filteredConfig.merge(defaultHistogramConfig()), scale), + return registerMeterIfNecessary( + DistributionSummary.class, id, distributionStatisticConfig, null, (registry, mappedId, filteredConfig, + pd) -> registry.newDistributionSummary(mappedId, filteredConfig, scale), NoopDistributionSummary::new); } @@ -392,7 +403,8 @@ DistributionSummary summary(Meter.Id id, DistributionStatisticConfig distributio * @return The meter. */ Meter register(Meter.Id id, Meter.Type type, Iterable measurements) { - return registerMeterIfNecessary(Meter.class, id, id2 -> newMeter(id2, type, measurements), NoopMeter::new); + return registerMeterIfNecessary(Meter.class, id, + (registry, mappedId) -> registry.newMeter(mappedId, type, measurements), NoopMeter::new); } /** @@ -444,7 +456,7 @@ public RequiredSearch get(String name) { * @return A new or existing counter. */ public Counter counter(String name, Iterable tags) { - return Counter.builder(name).tags(tags).register(this); + return counter(name, Tags.of(tags)); } /** @@ -458,6 +470,17 @@ public Counter counter(String name, String... tags) { return counter(name, Tags.of(tags)); } + /** + * Tracks a monotonically increasing value. + * @param name The base metric name + * @param tags Sequence of dimensions for breaking down the name. + * @return A new or existing counter. + * @since 1.16.0 + */ + public Counter counter(String name, Tags tags) { + return counter(new Meter.Id(name, tags, null, null, Meter.Type.COUNTER)); + } + /** * Measures the distribution of samples. * @param name The base metric name @@ -486,8 +509,7 @@ public DistributionSummary summary(String name, String... tags) { * @return A new or existing timer. */ public Timer timer(String name, Iterable tags) { - return this.timer(new Meter.Id(name, Tags.of(tags), null, null, Meter.Type.TIMER), - AbstractTimerBuilder.DEFAULT_DISTRIBUTION_CONFIG, pauseDetector); + return this.timer(name, Tags.of(tags)); } /** @@ -501,6 +523,18 @@ public Timer timer(String name, String... tags) { return timer(name, Tags.of(tags)); } + /** + * Measures the time taken for short tasks and the count of these tasks. + * @param name The base metric name + * @param tags Sequence of dimensions for breaking down the name. + * @return A new or existing timer. + * @since 1.16.0 + */ + public Timer timer(String name, Tags tags) { + return this.timer(new Meter.Id(name, tags, null, null, Meter.Type.TIMER), + AbstractTimerBuilder.DEFAULT_DISTRIBUTION_CONFIG, pauseDetector); + } + /** * Access to less frequently used meter types and patterns. * @return Access to additional meter types and patterns. @@ -607,14 +641,15 @@ public > T gaugeCollectionSize(String name, Iterable M registerMeterIfNecessary(Class meterClass, Meter.Id id, - Function builder, Function noopBuilder) { - return registerMeterIfNecessary(meterClass, id, null, (id2, conf) -> builder.apply(id2), noopBuilder); + BiFunction meterSupplier, Function noopBuilder) { + return registerMeterIfNecessary(meterClass, id, null, null, + (registry, mappedId, conf, pd) -> meterSupplier.apply(registry, mappedId), noopBuilder); } private M registerMeterIfNecessary(Class meterClass, Meter.Id id, - @Nullable DistributionStatisticConfig config, BiFunction builder, - Function noopBuilder) { - Meter m = getOrCreateMeter(config, builder, id, noopBuilder); + @Nullable DistributionStatisticConfig config, @Nullable PauseDetector specificPauseDetector, + NewMeterSupplier meterSupplier, Function noopBuilder) { + Meter m = getOrCreateMeter(config, specificPauseDetector, meterSupplier, id, noopBuilder); if (!meterClass.isInstance(m)) { throw new IllegalArgumentException( @@ -644,7 +679,7 @@ private Meter.Id mapId(Meter.Id id) { } private Meter getOrCreateMeter(@Nullable DistributionStatisticConfig config, - BiFunction builder, + @Nullable PauseDetector specificPauseDetector, NewMeterSupplier meterSupplier, Meter.Id originalId, Function noopBuilder) { Meter m = preFilterIdToMeterMap.get(originalId); @@ -684,9 +719,10 @@ private Meter getOrCreateMeter(@Nullable DistributionStatisticConfig config, config = filteredConfig; } } + config = config.merge(defaultHistogramConfig()); } - m = builder.apply(mappedId, config); + m = meterSupplier.create(this, mappedId, config, specificPauseDetector); Meter.Id synAssoc = mappedId.syntheticAssociation(); if (synAssoc != null) { @@ -1058,21 +1094,33 @@ public LongTaskTimer longTaskTimer(String name, String... tags) { * @return A new or existing long task timer. */ public LongTaskTimer longTaskTimer(String name, Iterable tags) { - return longTaskTimer(new Meter.Id(name, Tags.of(tags), null, null, Meter.Type.LONG_TASK_TIMER), + return longTaskTimer(name, Tags.of(tags)); + } + + /** + * Measures the time taken for long tasks. + * @param name Name of the long task timer being registered. + * @param tags Sequence of dimensions for breaking down the name. + * @return A new or existing long task timer. + * @since 1.16.0 + */ + public LongTaskTimer longTaskTimer(String name, Tags tags) { + return longTaskTimer(new Meter.Id(name, tags, null, null, Meter.Type.LONG_TASK_TIMER), LongTaskTimer.Builder.DEFAULT_DISTRIBUTION_CONFIG); } /** - * Only used by {@link LongTaskTimer#builder(String)}. + * Only used internally. * @param id The identifier for this long task timer. * @return A new or existing long task timer. + * @implNote Avoids allocation in the case the meter is already registered, + * particularly capturing lambdas. */ LongTaskTimer longTaskTimer(Meter.Id id, DistributionStatisticConfig distributionStatisticConfig) { - return registerMeterIfNecessary(LongTaskTimer.class, id, distributionStatisticConfig, - (id2, filteredConfig) -> { - Meter.Id withUnit = id2.withBaseUnit(getBaseTimeUnitStr()); - return newLongTaskTimer(withUnit, filteredConfig.merge(defaultHistogramConfig())); - }, NoopLongTaskTimer::new); + return registerMeterIfNecessary(LongTaskTimer.class, id, distributionStatisticConfig, null, + (registry, mappedId, mappedConfig, pd) -> registry + .newLongTaskTimer(mappedId.withBaseUnit(registry.getBaseTimeUnitStr()), mappedConfig), + NoopLongTaskTimer::new); } /** @@ -1116,7 +1164,8 @@ public FunctionCounter counter(String name, Iterable tag */ FunctionCounter counter(Meter.Id id, T obj, ToDoubleFunction countFunction) { return registerMeterIfNecessary(FunctionCounter.class, id, - id2 -> newFunctionCounter(id2, obj, countFunction), NoopFunctionCounter::new); + (registry, mappedId) -> registry.newFunctionCounter(mappedId, obj, countFunction), + NoopFunctionCounter::new); } /** @@ -1157,10 +1206,10 @@ public FunctionTimer timer(String name, Iterable tags, T obj, ToLongFun */ FunctionTimer timer(Meter.Id id, T obj, ToLongFunction countFunction, ToDoubleFunction totalTimeFunction, TimeUnit totalTimeFunctionUnit) { - return registerMeterIfNecessary(FunctionTimer.class, id, id2 -> { - Meter.Id withUnit = id2.withBaseUnit(getBaseTimeUnitStr()); - return newFunctionTimer(withUnit, obj, countFunction, totalTimeFunction, totalTimeFunctionUnit); - }, NoopFunctionTimer::new); + return registerMeterIfNecessary(FunctionTimer.class, id, + (registry, mappedId) -> registry.newFunctionTimer(mappedId.withBaseUnit(getBaseTimeUnitStr()), obj, + countFunction, totalTimeFunction, totalTimeFunctionUnit), + NoopFunctionTimer::new); } /** @@ -1198,7 +1247,8 @@ public TimeGauge timeGauge(String name, Iterable tags, T obj, TimeUnit TimeGauge timeGauge(Meter.Id id, @Nullable T obj, TimeUnit timeFunctionUnit, ToDoubleFunction timeFunction) { return registerMeterIfNecessary(TimeGauge.class, id, - id2 -> newTimeGauge(id2, obj, timeFunctionUnit, timeFunction), NoopTimeGauge::new); + (registry, mappedId) -> registry.newTimeGauge(mappedId, obj, timeFunctionUnit, timeFunction), + NoopTimeGauge::new); } } @@ -1242,6 +1292,27 @@ protected void meterRegistrationFailed(Meter.Id id, @Nullable String reason) { } } + @FunctionalInterface + // Brittle, but this is internal. We pass nulls for some meter types as explained in + // JavaDoc. + @NullUnmarked + private interface NewMeterSupplier { + + /** + * Create a new meter with the given parameters. The DistributionStatisticConfig + * and PauseDetector will be null for meter types that do not take them. + * @param registry the registry from which to make the new meter + * @param id the ID of the meter to create + * @param distributionStatisticConfig optional distribution config for types that + * take it + * @param pauseDetector optional pause detector for types that take it + * @return a new meter + */ + @NonNull M create(MeterRegistry registry, Meter.Id id, DistributionStatisticConfig distributionStatisticConfig, + PauseDetector pauseDetector); + + } + // VisibleForTesting Map _getPreFilterIdToMeterMap() { return Collections.unmodifiableMap(preFilterIdToMeterMap); diff --git a/micrometer-core/src/main/java/io/micrometer/core/instrument/distribution/pause/NoPauseDetector.java b/micrometer-core/src/main/java/io/micrometer/core/instrument/distribution/pause/NoPauseDetector.java index 275729207a..cccfb5d3e7 100644 --- a/micrometer-core/src/main/java/io/micrometer/core/instrument/distribution/pause/NoPauseDetector.java +++ b/micrometer-core/src/main/java/io/micrometer/core/instrument/distribution/pause/NoPauseDetector.java @@ -15,6 +15,22 @@ */ package io.micrometer.core.instrument.distribution.pause; +/** + * No-op implementation of a {@link PauseDetector}. + */ public class NoPauseDetector implements PauseDetector { + /** + * Singleton instance of {@link NoPauseDetector}. + * @since 1.16.0 + */ + public static final NoPauseDetector INSTANCE = new NoPauseDetector(); + + /** + * @deprecated use {@link #INSTANCE} instead. + */ + @Deprecated + public NoPauseDetector() { + } + } diff --git a/micrometer-core/src/test/java/io/micrometer/core/instrument/AbstractTimerTests.java b/micrometer-core/src/test/java/io/micrometer/core/instrument/AbstractTimerTests.java index 3b5d6b37be..aa0ea46d51 100644 --- a/micrometer-core/src/test/java/io/micrometer/core/instrument/AbstractTimerTests.java +++ b/micrometer-core/src/test/java/io/micrometer/core/instrument/AbstractTimerTests.java @@ -75,7 +75,7 @@ static class MyTimer extends AbstractTimer { MyTimer() { super(new Meter.Id("name", Tags.empty(), null, null, Meter.Type.TIMER), Clock.SYSTEM, - DistributionStatisticConfig.DEFAULT, new NoPauseDetector(), TimeUnit.SECONDS, false); + DistributionStatisticConfig.DEFAULT, NoPauseDetector.INSTANCE, TimeUnit.SECONDS, false); } @Override diff --git a/micrometer-core/src/test/java/io/micrometer/core/instrument/MeterRegistryAllocationTest.java b/micrometer-core/src/test/java/io/micrometer/core/instrument/MeterRegistryAllocationTest.java new file mode 100644 index 0000000000..53f469d1b0 --- /dev/null +++ b/micrometer-core/src/test/java/io/micrometer/core/instrument/MeterRegistryAllocationTest.java @@ -0,0 +1,138 @@ +/* + * Copyright 2025 VMware, Inc. + * + * 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 io.micrometer.core.instrument; + +import com.sun.management.ThreadMXBean; +import io.micrometer.core.Issue; +import io.micrometer.core.instrument.distribution.pause.NoPauseDetector; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; + +import java.lang.management.ManagementFactory; + +import static io.micrometer.core.instrument.MeterRegistryAllocationTest.JAVA_VM_NAME_J9_REGEX; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test expected allocation (or lack thereof) for methods expected to be used on hot paths + * from {@link MeterRegistry}. + */ +@DisabledIfSystemProperty(named = "java.vm.name", matches = JAVA_VM_NAME_J9_REGEX, + disabledReason = "Sun ThreadMXBean with allocation counter not available on J9 JVM") +class MeterRegistryAllocationTest { + + // Should match "Eclipse OpenJ9 VM" and "IBM J9 VM" + static final String JAVA_VM_NAME_J9_REGEX = ".*J9 VM$"; + + MeterRegistry registry = new SimpleMeterRegistry(); + + Tags tags = Tags.of("a", "b", "c", "d"); + + static ThreadMXBean threadMXBean = (ThreadMXBean) ManagementFactory.getThreadMXBean(); + + /** + * Number of bytes allocated when constructing a Meter.Id. This can vary by JVM and + * configuration, which is why we measure it programmatically. For most common cases, + * it should be 40 bytes. + */ + static long newIdBytes; + + @BeforeAll + static void setup() { + // Construct once here so any static initialization doesn't get measured. + new Meter.Id("name", Tags.empty(), null, null, Meter.Type.OTHER); + newIdBytes = measureAllocatedBytes(() -> new Meter.Id("name", Tags.empty(), null, null, Meter.Type.OTHER)); + } + + private static long measureAllocatedBytes(Runnable runnable) { + long currentThreadId = Thread.currentThread().getId(); + long allocatedBytesBefore = threadMXBean.getThreadAllocatedBytes(currentThreadId); + + runnable.run(); + + return threadMXBean.getThreadAllocatedBytes(currentThreadId) - allocatedBytesBefore; + } + + @BeforeEach + void setUp() { + registry.timer("timer", tags); + LongTaskTimer.builder("ltt").tags(tags).register(registry); + Counter.builder("counter").tags(tags).register(registry); + } + + @Nested + @DisplayName("Internal implementation details") + class Internal { + + @Issue("#6670") + @Test + void retrieveExistingTimer_noAllocation() { + Meter.Id id = new Meter.Id("timer", tags, null, null, Meter.Type.TIMER); + assertNoAllocation(() -> registry.timer(id, AbstractTimerBuilder.DEFAULT_DISTRIBUTION_CONFIG, + NoPauseDetector.INSTANCE)); + } + + @Issue("#6670") + @Test + void retrieveExistingLongTaskTimer_noAllocation() { + Meter.Id id = new Meter.Id("ltt", tags, null, null, Meter.Type.LONG_TASK_TIMER); + assertNoAllocation( + () -> registry.more().longTaskTimer(id, LongTaskTimer.Builder.DEFAULT_DISTRIBUTION_CONFIG)); + } + + @Issue("#6670") + @Test + void retrieveExistingCounter_noAllocation() { + Meter.Id id = new Meter.Id("counter", tags, null, null, Meter.Type.COUNTER); + assertNoAllocation(() -> registry.counter(id)); + } + + } + + @Nested + @DisplayName("Public API available to users") + class PublicApi { + + @Issue("#6670") + @Test + void retrieveExistingTimer_onlyIdAllocation() { + assertBytesAllocated(newIdBytes, () -> registry.timer("timer", tags)); + } + + @Issue("#6670") + @Test + void retrieveExistingLongTaskTimer_onlyIdAllocation() { + assertBytesAllocated(newIdBytes, () -> registry.more().longTaskTimer("ltt", tags)); + } + + @Issue("#6670") + @Test + void retrieveExistingCounter_onlyIdAllocation() { + assertBytesAllocated(newIdBytes, () -> registry.counter("counter", tags)); + } + + } + + private void assertBytesAllocated(long bytes, Runnable runnable) { + assertThat(measureAllocatedBytes(runnable)).isEqualTo(bytes); + } + + private void assertNoAllocation(Runnable runnable) { + assertBytesAllocated(0, runnable); + } + +}