blob: 72e5599c9faabab7461d1226bff90bd1d2c00534 [file] [log] [blame]
/*******************************************************************************
* Copyright (C) 2014, 2019 Google Inc and others.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Steve Foreman (Google) - initial API and implementation
* Marcus Eng (Google)
* Sergey Prigogin (Google)
* Simon Scholz <simon.scholz@vogella.com> - Bug 443391
* Christoph Läubrich - change to new preference store API
*******************************************************************************/
package org.eclipse.ui.internal.monitoring;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.Random;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.eclipse.jface.preference.IPreferenceStore;
import org.eclipse.swt.widgets.Display;
import org.eclipse.ui.monitoring.PreferenceConstants;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
/**
* A test that measures performance overhead of {@link EventLoopMonitorThread}.
* This test is not included into {@link MonitoringTestSuite} due to its low reliability
* and the amount of time it takes.
*/
public class EventLoopMonitorThreadManualTests {
/** Change to {@code true} to enable printing of detailed information to the console. */
private static final boolean PRINT_TO_CONSOLE = false;
// Test Parameters
/** Time each measurement should run for. This will affect the number of samples collected. */
protected static final double TARGET_RUNNING_TIME_PER_MEASUREMENT = 5.0; // seconds
/** Maximum allowable relative increase due to taking traces of the UI thread. */
protected static final double MAX_RELATIVE_INCREASE_ONE_STACK_PERCENT = 2.5; // %
/** Maximum allowable relative increase per thread due to taking traces of all threads. */
protected static final double MAX_RELATIVE_INCREASE_PER_EXTRA_THREAD_PERCENT = 0.3; // %
/** Number of times to repeat the control measurement. This should always be at least 2. */
protected static final int NUM_CONTROL_MEASUREMENTS = 5;
/** Number of times to repeat the UI stack sampling measurement. */
protected static final int NUM_UI_STACK_MEASUREMENTS = 5;
/** Number of times to repeat the all stacks sampling measurement. */
protected static final int NUM_ALL_STACKS_MEASUREMENTS = 5;
// Calibration Tuning Parameters
/**
* Number of work units to integrate over while calibrating. This parameter is used to adjust
* how much work is done to capture some jitter but too large of value will oscillate with large
* events (e.g. GC) instead of converging.
*/
protected static final long WORK_INTEGRATION_ITERATIONS = 70000; // iterations
/**
* Number of consecutive iterations below the error threshold (defined by
* {@link #CAL_MAX_RELATIVE_ERROR}) before accepting a calibration result. Due to occasional
* jitter (e.g. OS scheduling, GC, etc), large values may not ever converge.
*/
protected static final int MIN_CONVERGING_ITERATIONS = 256; // iterations
/**
* Number of outliers, relative to samples, to tolerate while calculating convergence during
* calibration. Tolerated points are ignored (e.g. not factored into the rolling average).
*/
protected static final double MAX_OUTLIER_RATIO = 0.03;
/**
* Weight of new elements added to the exponential averaging of the mean during calibration.
* The optimal value is [2/(N-1)] where N is the desired number of samples to average.
*/
protected static final double CAL_EMA_ALPHA = 2.0 / (MIN_CONVERGING_ITERATIONS - 1.0);
/**
* Max relative error between each new estimated mean and the previous estimate.
* Once the relative error stabilizes below this threshold for long enough (defined by
* {@link #MIN_CONVERGING_ITERATIONS} iterations) the calibration is complete.
*/
protected static final double CAL_MAX_RELATIVE_ERROR = 0.003;
/**
* Pseudo-random Noise LFSR generator polynomial, degree = 64. Polynomial for maximum sequence
* length by Wayne Stahnke, Primitive Polynomials Modulo Two,
* http://www.ams.org/journals/mcom/1973-27-124/S0025-5718-1973-0327722-7/S0025-5718-1973-0327722-7.pdf
*
* <p>
* At 80ns/call, the sequence will have a period of &gt;23382 years (== (2^63-1) * 80ns)
*/
protected static final long PN63_GENERATOR_POLY = (3L << 62) | 1;
@Before
public void setUp() {
MonitoringPlugin.getPreferenceStore().setValue(PreferenceConstants.MONITORING_ENABLED, false);
}
@After
public void tearDown() {
MonitoringPlugin.getPreferenceStore().setToDefault(PreferenceConstants.MONITORING_ENABLED);
}
protected static long pn63(long pnSequence) {
int nextBit = 0;
for (int i = 0; i < 64; ++i) {
if (((PN63_GENERATOR_POLY >> i) & 1) == 1) {
nextBit ^= (pnSequence >> i) & 1;
}
}
return (pnSequence << 1) | nextBit;
}
protected static long doWork(long pnSeqeuence, long iterations) {
LinkedList<String> messages = new LinkedList<>();
for (long i = 0; i < iterations; ++i) {
pnSeqeuence = pn63(pnSeqeuence);
if (i % 10000 == 0) {
messages.add("10000!");
} else if (i % 1000 == 0) {
for (int c = messages.size() / 2; c >= 0; --c) {
messages.removeFirst();
}
} else if (i % 100 == 0) {
messages.add(String.format("Iteration %d", i));
}
}
for (String s : messages) {
pnSeqeuence ^= s.hashCode();
}
return pnSeqeuence;
}
/**
* Performance test for {@link EventLoopMonitorThread}. This test verifies that the monitoring
* doesn't interfere too much with the real work being done.
*/
@Test
public void testFixedWork() throws Exception{
final Display display = Display.getDefault();
assertNotNull("No SWT Display available.", display);
final MockUiFreezeEventLogger logger = new MockUiFreezeEventLogger();
final CountDownLatch backgroundJobsDone = new CountDownLatch(1);
Queue<Thread> threads = startBackgroundThreads(backgroundJobsDone);
double nsPerWork = calibrate(display);
final long numIterations = Math.round(1e9 * TARGET_RUNNING_TIME_PER_MEASUREMENT / nsPerWork);
final double[] tWork = {0};
final long[] workOutput = {0};
final Runnable doFixedAmountOfWork = () -> {
long start = System.nanoTime();
long result = doWork(start, numIterations);
tWork[0] = System.nanoTime() - start;
workOutput[0] ^= result;
};
// Fetch the total number of threads.
ThreadMXBean jvmThreadManager = ManagementFactory.getThreadMXBean();
boolean dumpLockedMonitors = jvmThreadManager.isObjectMonitorUsageSupported();
boolean dumpLockedSynchronizers = jvmThreadManager.isSynchronizerUsageSupported();
int totalThreadCount =
jvmThreadManager.dumpAllThreads(dumpLockedMonitors, dumpLockedSynchronizers).length;
List<Double> controlResults = new ArrayList<>(NUM_CONTROL_MEASUREMENTS);
List<Double> uiStackResults = new ArrayList<>(NUM_UI_STACK_MEASUREMENTS);
List<Double> allStacksResults = new ArrayList<>(NUM_ALL_STACKS_MEASUREMENTS);
double expectedRunningTime = 0.0;
double maxRunningTime = 0.0;
/*
* This could be made more precise by measuring the control loop many times, calculating
* the mean and standard deviation, measuring each of the test case a few times, calculating
* the probability of the result, and comparing that probability to some acceptable bound.
*/
if (PRINT_TO_CONSOLE) {
System.out.println(String.format("Starting %d control measurements without monitoring.",
NUM_CONTROL_MEASUREMENTS));
}
for (int i = 1; i <= NUM_CONTROL_MEASUREMENTS; ++i) {
display.syncExec(doFixedAmountOfWork);
if (PRINT_TO_CONSOLE) {
System.out.println(String.format("Control measurement %d/%d finished. tWork = %fs",
i, NUM_CONTROL_MEASUREMENTS, tWork[0] / 1e9));
}
controlResults.add(tWork[0]);
expectedRunningTime += tWork[0] / NUM_CONTROL_MEASUREMENTS;
if (tWork[0] > maxRunningTime) {
maxRunningTime = tWork[0];
}
}
// Calculate error bound between mean and max control runs.
double controlDiff = Math.abs((maxRunningTime - expectedRunningTime) / expectedRunningTime);
double maxRelativeIncreaseOneStackAllowed =
(MAX_RELATIVE_INCREASE_ONE_STACK_PERCENT / 100.) * (1 + controlDiff);
double maxRelativeIncreaseAllStacksAllowed =
((MAX_RELATIVE_INCREASE_ONE_STACK_PERCENT + MAX_RELATIVE_INCREASE_PER_EXTRA_THREAD_PERCENT
* (totalThreadCount - 1)) / 100.) * (1 + controlDiff);
Thread monitor1 = createAndStartMonitoringThread(display, false);
double worstRelativeDiffOneThread = Double.MIN_NORMAL;
if (PRINT_TO_CONSOLE) {
System.out.println(
String.format("Starting %d measurements while collecting UI thread stacks.",
NUM_UI_STACK_MEASUREMENTS));
}
for (int i = 1; i <= NUM_UI_STACK_MEASUREMENTS; ++i) {
display.syncExec(doFixedAmountOfWork);
uiStackResults.add(tWork[0]);
double relativeDiffOneThread = (tWork[0] - expectedRunningTime) / expectedRunningTime;
if (relativeDiffOneThread > worstRelativeDiffOneThread) {
worstRelativeDiffOneThread = relativeDiffOneThread;
}
if (PRINT_TO_CONSOLE) {
System.out.println(String.format(
"Measurement %d of %d took %.3fs. Relative increase = %.3f%% "
+ "(allowed < %.3f%%).",
i, NUM_UI_STACK_MEASUREMENTS, tWork[0] / 1e9, relativeDiffOneThread * 100,
maxRelativeIncreaseOneStackAllowed * 100));
}
assertTrue(
String.format("Relative overhead of monitoring surpassed threshold for "
+ "measurement %d of %d. It took %.3fs with a relative increase of %.3f%% "
+ "(allowed < %.3f%%).",
i, NUM_UI_STACK_MEASUREMENTS, tWork[0] / 1e9, relativeDiffOneThread * 100,
maxRelativeIncreaseOneStackAllowed * 100),
relativeDiffOneThread <= maxRelativeIncreaseOneStackAllowed);
}
killMonitorThread(monitor1, display);
Thread monitor2 = createAndStartMonitoringThread(display, true);
double worstRelativeDiffAllThreads = Double.MIN_NORMAL;
if (PRINT_TO_CONSOLE) {
System.out.println(
String.format("Starting %d measurements while collecting all thread stacks.",
NUM_ALL_STACKS_MEASUREMENTS));
}
for (int i = 1; i <= NUM_ALL_STACKS_MEASUREMENTS; ++i) {
display.syncExec(doFixedAmountOfWork);
allStacksResults.add(tWork[0]);
double relativeDiffAllThreads = (tWork[0] - expectedRunningTime) / expectedRunningTime;
if (relativeDiffAllThreads > worstRelativeDiffAllThreads) {
worstRelativeDiffAllThreads = relativeDiffAllThreads;
}
if (PRINT_TO_CONSOLE) {
System.out.println(String.format(
"Measurement %d of %d took %.3fs, Relative increase = %.3f%% "
+ "(allowed < %.3f%%).",
i, NUM_ALL_STACKS_MEASUREMENTS, tWork[0] / 1e9, relativeDiffAllThreads * 100,
maxRelativeIncreaseAllStacksAllowed * 100));
}
assertTrue(
String.format("Relative overhead of monitoring with stack traces of all threads "
+ "surpassed threshold for measurement %d of %d. It took %.3fs with a relative "
+ "increase of %.3f%% (allowed < %.3f%%).",
i, NUM_ALL_STACKS_MEASUREMENTS, tWork[0] / 1e9, relativeDiffAllThreads * 100,
maxRelativeIncreaseAllStacksAllowed * 100),
relativeDiffAllThreads <= maxRelativeIncreaseAllStacksAllowed);
}
killMonitorThread(monitor2, display);
backgroundJobsDone.countDown();
if (PRINT_TO_CONSOLE) {
// Tabulate final results while waiting for monitor to finish.
double controlMean = 0;
double controlMin = Double.MAX_VALUE;
double controlMax = Double.MIN_NORMAL;
double controlM2 = 0;
for (int n = 0; n < controlResults.size(); ) {
double v = controlResults.get(n) / 1e9;
controlResults.set(n, v);
++n;
double delta = v - controlMean;
controlMean += delta / n;
controlM2 += delta * (v - controlMean);
controlMin = Math.min(controlMin, v);
controlMax = Math.max(controlMax, v);
}
double controlStD = Math.sqrt(controlM2 / controlResults.size());
double uiStackMean = 0;
double uiStackMin = Double.MAX_VALUE;
double uiStackMax = Double.MIN_NORMAL;
double uiStackM2 = 0;
for (int n = 0; n < uiStackResults.size(); ) {
double v = uiStackResults.get(n) / 1e9;
uiStackResults.set(n, v);
++n;
double delta = v - uiStackMean;
uiStackMean += delta / n;
uiStackM2 += delta * (v - uiStackMean);
uiStackMin = Math.min(uiStackMin, v);
uiStackMax = Math.max(uiStackMax, v);
}
double allStacksMean = 0;
double allStacksMin = Double.MAX_VALUE;
double allStacksMax = Double.MIN_NORMAL;
double allStacksM2 = 0;
for (int n = 0; n < allStacksResults.size(); ) {
double v = allStacksResults.get(n) / 1e9;
allStacksResults.set(n, v);
++n;
double delta = v - allStacksMean;
allStacksMean += delta / n;
allStacksM2 += delta * (v - allStacksMean);
allStacksMin = Math.min(allStacksMin, v);
allStacksMax = Math.max(allStacksMax, v);
}
// Ensure that the work cannot be optimized away completely.
System.out.println(String.format("Work result = %016X", workOutput[0]));
if (!(worstRelativeDiffOneThread <= maxRelativeIncreaseOneStackAllowed)) {
System.out.println(" * Monitoring UI thread slowed down work too much");
}
if (!(worstRelativeDiffAllThreads <= maxRelativeIncreaseAllStacksAllowed)) {
System.out.println(" * Monitoring all threads slowed down work too much");
}
if (logger.getLoggedEvents().size() != NUM_UI_STACK_MEASUREMENTS + NUM_ALL_STACKS_MEASUREMENTS) {
System.out.println(String.format(" * Didn't get expected freeze events (got %d/%d)",
logger.getLoggedEvents().size(),
NUM_UI_STACK_MEASUREMENTS + NUM_ALL_STACKS_MEASUREMENTS));
}
System.out.println("\nRaw results (in seconds): ");
System.out.println(String.format(
" * Control (min = %f max = %f avg = %f stD = %f): %s",
controlMin, controlMax, controlMean, controlStD, joinItems(controlResults)));
System.out.println(String.format(
" * UI stack (min = %f max = %f avg = %f stD = %f, vsC = %f): %s",
uiStackMin, uiStackMax, uiStackMean, Math.sqrt(uiStackM2 / uiStackResults.size()),
Math.abs(uiStackMean - controlMean) / controlStD, joinItems(uiStackResults)));
System.out.println(String.format(
" * All stacks (min = %f max = %f avg = %f stD = %f, vsC = %f): %s",
allStacksMin, allStacksMax, allStacksMean, Math.sqrt(allStacksM2 / allStacksResults.size()),
Math.abs(allStacksMean - controlMean) / controlStD,
joinItems(allStacksResults)));
}
// Join all threads.
while (!threads.isEmpty()) {
Thread t = threads.poll();
try {
t.join();
} catch (InterruptedException e) {
threads.offer(t); // Retry.
}
}
assertEquals("Did not log expected number of freeze events,",
NUM_UI_STACK_MEASUREMENTS + NUM_ALL_STACKS_MEASUREMENTS,
logger.getLoggedEvents().size());
assertTrue(String.format("Relative overhead of monitoring with stack traces of the UI "
+ "thread was %.3f%% (allowed < %.3f%%).",
worstRelativeDiffOneThread * 100,
maxRelativeIncreaseOneStackAllowed * 100),
worstRelativeDiffOneThread <= maxRelativeIncreaseOneStackAllowed);
assertTrue(String.format("Relative overhead of monitoring with stack traces of all "
+ "threads was %.3f%% (allowed < %.3f%%).",
worstRelativeDiffAllThreads * 100,
maxRelativeIncreaseAllStacksAllowed * 100),
worstRelativeDiffAllThreads <= maxRelativeIncreaseAllStacksAllowed);
}
private Thread createAndStartMonitoringThread(Display display, boolean dumpAll) throws Exception {
// Let the monitoring thread to go to sleep twice before considering it ready.
CountDownLatch monitorStarted = new CountDownLatch(2);
Thread monitor = createTestMonitor(dumpAll, monitorStarted);
startMonitoring(display, monitor, monitorStarted);
return monitor;
}
private void killMonitorThread(final Thread thread, Display display) throws Exception {
display.syncExec(() -> shutdownThread(thread));
thread.join();
}
protected static EventLoopMonitorThread.Parameters createDefaultParameters() {
IPreferenceStore preferences = MonitoringPlugin.getPreferenceStore();
EventLoopMonitorThread.Parameters params = new EventLoopMonitorThread.Parameters();
params.longEventWarningThreshold =
preferences.getInt(PreferenceConstants.LONG_EVENT_WARNING_THRESHOLD_MILLIS);
params.longEventErrorThreshold =
preferences.getInt(PreferenceConstants.LONG_EVENT_ERROR_THRESHOLD_MILLIS);
params.maxStackSamples = preferences.getInt(PreferenceConstants.MAX_STACK_SAMPLES);
params.deadlockThreshold =
preferences.getInt(PreferenceConstants.DEADLOCK_REPORTING_THRESHOLD_MILLIS);
params.uiThreadFilter = preferences.getString(PreferenceConstants.UI_THREAD_FILTER);
params.noninterestingThreadFilter =
preferences.getString(PreferenceConstants.NONINTERESTING_THREAD_FILTER);
params.checkParameters();
return params;
}
protected Thread createTestMonitor(boolean dumpAllThreads, final CountDownLatch monitorStarted)
throws Exception {
EventLoopMonitorThread.Parameters args = createDefaultParameters();
if (dumpAllThreads) {
args.longEventErrorThreshold = args.longEventWarningThreshold;
}
return new EventLoopMonitorThread(args) {
@Override
protected void sleepForMillis(long nanoseconds) {
monitorStarted.countDown();
super.sleepForMillis(nanoseconds);
}
};
}
protected Queue<Thread> startBackgroundThreads(final CountDownLatch backgroundJobsDone) {
final Runnable backgroundTaskRunnable = () -> {
final double dutyCycle = 0.10;
final double min = 100; // ns
final double max = 1e9; // ns
final double skew = 0.1; // the degree to which the values cluster around the mode
final double bias = -1e5; // bias the mode to approach the min (< 0) vs max (> 0)
double range = max - min;
double mid = min + range / 2.0;
double biasFactor = Math.exp(bias);
Random rng = new Random();
while (true) {
double rv = rng.nextGaussian();
double runFor = mid + (range * (biasFactor / (biasFactor + Math.exp(-rv / skew)) - 0.5));
long endTime = System.nanoTime() + (long) runFor;
do {
doWork(rng.nextInt(), (int) runFor);
} while (endTime - System.nanoTime() > 0);
double sleepScale = Math.abs(rng.nextGaussian() / dutyCycle);
try {
if (backgroundJobsDone.await((int) Math.round(runFor * sleepScale),
TimeUnit.NANOSECONDS)) {
return;
}
} catch (InterruptedException e) {
// Wake up.
}
}
};
final Runnable backgroundIdle = () -> {
while (true) {
try {
backgroundJobsDone.await();
return;
} catch (InterruptedException e) {
// Wake up.
}
}
};
Random rng = new Random();
int activeThreads = Thread.activeCount();
int numBackgroundTasks = activeThreads <= 2 ? 5 + rng.nextInt(5) : 1;
int numBackgroundIdlers = 8 + rng.nextInt(30);
if (PRINT_TO_CONSOLE) {
System.out.println(String.format("Starting %d background tasks and %d background idlers",
numBackgroundTasks, numBackgroundIdlers));
}
Queue<Thread> threads = new ArrayDeque<>(numBackgroundIdlers + numBackgroundTasks);
for (int i = 0; i < numBackgroundTasks; i++) {
Thread t = new Thread(backgroundTaskRunnable);
threads.add(t);
t.start();
}
for (int i = 0; i < numBackgroundIdlers; i++) {
Thread t = new Thread(backgroundIdle);
threads.add(t);
t.start();
}
return threads;
}
protected void startMonitoring(final Display display, final Thread monitorThread,
CountDownLatch eventsRegistered) throws Exception {
display.syncExec(() -> {
monitorThread.start();
// If we're still running when display gets disposed, shutdown the thread.
display.disposeExec(() -> shutdownThread(monitorThread));
});
for (boolean eventsReady = false; !eventsReady;) {
while (display.readAndDispatch()) { // Keep invoking events.
}
eventsReady |= eventsRegistered.await(1, TimeUnit.MILLISECONDS);
}
}
protected void shutdownThread(Thread monitor) {
((EventLoopMonitorThread) monitor).shutdown();
}
/**
* Returns nanoseconds/unitWork
*/
protected double calibrate(final Display display) {
if (PRINT_TO_CONSOLE) {
System.out.println("Starting calibration...");
}
final double[] tWork = {0};
display.syncExec(() -> tWork[0] = measureAvgTimePerWorkItem());
if (PRINT_TO_CONSOLE) {
System.out.println(String.format("Calibration finished. tWorkUnit = %fns", tWork[0]));
}
return tWork[0];
}
protected double measureAvgTimePerWorkItem() {
int consecutiveGood = 0;
int outliers = 0;
int hash = 0;
double avgAbsRelErr = 0;
double numSamplesAveraged = 2.0 / CAL_EMA_ALPHA + 1.0;
// Ensure everything is in memory, JITed, and ready to go.
doWork(0, 1000);
// Initialize a meaningful mean
long start = System.nanoTime();
long result = doWork(0, WORK_INTEGRATION_ITERATIONS);
double mean = System.nanoTime() - start;
double oldMean;
long n = 0;
long startWallTime = System.currentTimeMillis();
long nextPrintAt = startWallTime + 5000; // 5s
while (++n < Long.MAX_VALUE
&& (consecutiveGood < MIN_CONVERGING_ITERATIONS || avgAbsRelErr > CAL_MAX_RELATIVE_ERROR)) {
start = System.nanoTime();
result = doWork(result, WORK_INTEGRATION_ITERATIONS);
long duration = System.nanoTime() - start;
hash ^= result;
double deltaDuration = duration - mean;
oldMean = mean;
mean += CAL_EMA_ALPHA * (deltaDuration - mean); // EMA
double absRelErr = Math.abs((oldMean - mean) / mean);
if (absRelErr < CAL_MAX_RELATIVE_ERROR) {
consecutiveGood++;
avgAbsRelErr = avgAbsRelErr * ((consecutiveGood - 1) / consecutiveGood)
+ (absRelErr / consecutiveGood);
} else if (outliers < (int) (MAX_OUTLIER_RATIO * Math.min(n, numSamplesAveraged))) {
++outliers;
} else {
consecutiveGood = 1;
outliers = 0;
avgAbsRelErr = absRelErr;
}
if (nextPrintAt - System.currentTimeMillis() < 0) {
nextPrintAt += 2000; // 2s
if (PRINT_TO_CONSOLE) {
System.out.println(String.format(
"Still calibrating... n = %3d\t\ttWork = %f ns\t\tRelErr = %f\t\tOutliers = %d/%d",
n, 2.0 * mean / WORK_INTEGRATION_ITERATIONS, avgAbsRelErr, outliers,
(int) (MAX_OUTLIER_RATIO * Math.min(n, numSamplesAveraged))));
}
}
}
double tWork = 2.0 * mean / WORK_INTEGRATION_ITERATIONS;
// Passing the hash to println method ensures that it cannot be optimized away completely.
System.out.println(String.format("Measurement converged in %d ms (%d loops) "
+ "tWork = %.3fns, relErr = %f, outliers = %d",
System.currentTimeMillis() - startWallTime,
n,
tWork,
avgAbsRelErr,
outliers,
hash));
return tWork;
}
private String joinItems(List<Double> items) {
StringBuilder mergedItems = new StringBuilder();
for (double item : items) {
if (mergedItems.length() != 0) {
mergedItems.append(',');
}
mergedItems.append(item);
}
return mergedItems.toString();
}
}