Let’s take a look at the performance of Java, Spring Boot, and MongoDB to see where the JDK is spending the most time. We’ll also cover high-impact, low-variation performance improvements.

Average calculation of AngularAndSpring projects run at startup (@Async + @EventListener) or once per day (@scheduled) quotes. It is implemented in the PrepareDataTask class. It is started at startup by the TaskStarter class. It calculates the average of newly available offers. Average quotes for several years of data must be recalculated to make performance interesting.

Everything was done on Linux X64 using the Terumium Jdk 17 and MongoDb 4.4.

Prepare data at startup

To run calculations on application startup, TaskStarter has the following initAvgs() methods:

@Async
@EventListener(ApplicationReadyEvent.class)
public void initAvgs() {
	log.info("ApplicationReady");
	this.prepareDataTask.createBsAvg();
	this.prepareDataTask.createBfAvg();
	this.prepareDataTask.createIbAvg();
	this.prepareDataTask.createCbHAvg();
}
Copy the code

The @async ‘annotation runs the method on a different thread so that startup can be completed before the method completes.

‘@ EventListener (ApplicationReadyEvent. Class)’ began to accept the request before the application runs on ApplicationReadEvent method.

The methods are then called sequentially and the annotations are processed because the methods are called from different classes.

Prepare data using Cron

The PrepareDataTask class has methods such as this to start an average calculation task:

@Scheduled(cron = "0 10 2 ? *?" ) @SchedulerLock(name = "coinbase_avg_scheduledTask", lockAtLeastFor = "PT1M", Timed(value = "create.cb.avg", percentiles = {0.5, 0.95, 0.99}) public void createCbHAvg () {this. CoinbaseService. CreateCbAvg (); }Copy the code

The @scheduled ‘annotation runs this method every day at 2.10.

The ‘@schedulerlock’ annotation stops a database entry from running twice. The name of each lock must be unique. Database locks ensure that jobs are started on an instance only with horizontal scaling.

The ‘@timed’ annotation tells Mirometer to record the percentile of the method’s run time.

Run the create average method

The BitfinexService, BitstampService, ItbitService, CoinbaseService classes have a method to start averaging, which is shown here as Coinbase: createAvg

public void createCbAvg() { LocalDateTime start = LocalDateTime.now(); log.info("CpuConstraint property: " + this.cpuConstraint); if (this.cpuConstraint) { this.createCbHourlyAvg(); this.createCbDailyAvg(); log.info(this.serviceUtils.createAvgLogStatement(start, "Prepared Coinbase Data Time:")); } else { // This can only be used on machines without // cpu constraints. CompletableFuture<String> future7 = CompletableFuture .supplyAsync(() -> { this.createCbHourlyAvg(); return "createCbHourlyAvg() Done."; }, CompletableFuture. delayedExecutor(10, TimeUnit.SECONDS)); CompletableFuture<String> future8 = CompletableFuture. supplyAsync(() -> { this.createCbDailyAvg(); return "createCbDailyAvg() Done."; }, CompletableFuture. delayedExecutor(10, TimeUnit.SECONDS)); String combined = Stream.of(future7, future8) .map(CompletableFuture::join) .collect(Collectors.joining(" ")); log.info(combined); }}Copy the code

First cpuConstraint logs and checks this attribute. It by the environment variable “” in the application. The propertiesCPU_CONSTRAINT file set, the default value is” false “. It should be set to true in Kubernetes deployments where the application has less than 2 cpus available.

If the cpuConstraint attribute is set to true, the ‘createCbHourlyAvg()’ and’ createCbDailyAvg()’ methods will run in sequence to reduce CPU load.

If the cpuConstraint attribute is set to false, both the ‘createCbHourlyAvg()’ and’ createCbDailyAvg()’ methods run CompletableFutures. DelayedExecutor is used to give MongoDb a few seconds to settle down between jobs.

‘Stream’ is used to wait for CompletableFutures and the result of connecting them.

Then record the results.

Calculate the average

BitfinexService, BitstampService, ItbitService, CoinbaseService classes have create?? Avg method. Take CoinbaseService’s ‘createCbHourlyAvg()’ for example:

private void createCbHourlyAvg() {
	LocalDateTime startAll = LocalDateTime.now();
	MyTimeFrame timeFrame = this.serviceUtils.
                createTimeFrame(CB_HOUR_COL, QuoteCb.class, true);
	SimpleDateFormat sdf = new SimpleDateFormat("dd.MM.yyyy");
	Calendar now = Calendar.getInstance();
        now.setTime(Date.from(LocalDate.now()
                .atStartOfDay().atZone(ZoneId.
                 systemDefault()).toInstant()));
	while (timeFrame.end().before(now)) {
		Date start = new Date();
		Query query = new Query();
		query.addCriteria(				 
                        Criteria.where("createdAt").gt(timeFrame.
                        begin().getTime()).
                        lt(timeFrame.end().getTime()));
		// Coinbase
		Mono<Collection<QuoteCb>> collectCb = 
                        this.myMongoRepository.
                        find(query, QuoteCb.class).collectList()
                             .map(quotes -> 
                                      makeCbQuoteHour(quotes, 
                                      timeFrame.begin(),
                                      timeFrame.end()));
			this.myMongoRepository.insertAll(collectCb, 
                              CB_HOUR_COL).blockLast();
		timeFrame.begin().add(Calendar.DAY_OF_YEAR, 1);
		timeFrame.end().add(Calendar.DAY_OF_YEAR, 1);
		log.info("Prepared Coinbase Hour Data for: " + 
                sdf.format(timeFrame.begin().getTime()) + " Time: "
                        + (new Date().getTime() - start.getTime()) 
                        + "ms");
	}
	log.info(this.serviceUtils.createAvgLogStatement(startAll,
                "Prepared Coinbase Hourly Data Time:"));
}
Copy the code

‘ createTimeFrame(…) The ‘method finds the last average hourly document in the collection or the first entry in the quote collection and returns the first day to calculate the average.

In the while loop, calculate the hourly average for the day. First, set the search criteria for the “createdAt” time range for the day. The ‘createdAt’ property has an index to improve search performance. The project uses a reactive MongoDb driver. Thus, ‘the find (…). The.collectList()’ method returns a quote that Mono<Collection>(Spring Reactor) maps to the mean.

Then combine that Mono with “insertAll(…)” .blocklast () “stored together. ‘blockLast()’ starts the reaction flow and ensures that the average is stored.

Then set “timeFrame” to the next day and write the log entry.

Use large POJOs

The QuoteCb class looks like this:

@Document public class QuoteCb implements Quote { @Id private ObjectId _id; @Indexed @JsonProperty private Date createdAt = new Date(); private final BigDecimal aed; private final BigDecimal afn; private final BigDecimal all; private final BigDecimal amd; . // 150 properties moreCopy the code

Pojos are used for MongoDb ‘@document’ and ‘@id’ and ‘@indexed’ ‘createdAt’. It has over 150 “BigDecimal” attributes and a constructor to set them. To avoid having to write a mapper to get and set values, the CoinbaseService class has’ avgCbQuotePeriod(…) ‘and’ createGetMethodHandle (…). Method:

private QuoteCb avgCbQuotePeriod(QuoteCb q1, QuoteCb q2, long count) { Class[] types = new Class[170]; for (int i = 0; i < 170; i++) { types[i] = BigDecimal.class; } QuoteCb result = null; try { BigDecimal[] bds = new BigDecimal[170]; IntStream.range(0, QuoteCb.class.getConstructor(types) .getParameterAnnotations().length) .forEach(x -> { try { MethodHandle mh = createGetMethodHandle(types, x); BigDecimal num1 = (BigDecimal) mh.invokeExact(q1); BigDecimal num2 = (BigDecimal) mh.invokeExact(q2); bds[x] = this.serviceUtils .avgHourValue(num1, num2, count); } catch (Throwable e) { throw new RuntimeException(e); }}); result = QuoteCb.class.getConstructor(types) .newInstance((Object[]) bds); result.setCreatedAt(q1.getCreatedAt()); } catch (NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { throw new RuntimeException(e); } return result; } private MethodHandle createGetMethodHandle(Class[] types, int x) throws NoSuchMethodException, IllegalAccessException { MethodHandle mh = cbMethodCache.get(Integer.valueOf(x)); if (mh == null) { synchronized (this) { mh = cbMethodCache.get(Integer.valueOf(x)); if (mh == null) { JsonProperty annotation = (JsonProperty) QuoteCb.class. getConstructor(types).getParameterAnnotations()[x][0]; String fieldName = annotation.value(); String methodName = String.format("get%s%s", fieldName.substring(0, 1).toUpperCase(), fieldName.substring(1).toLowerCase()); if ("getTry".equals(methodName)) { methodName = methodName + "1"; } MethodType desc = MethodType .methodType(BigDecimal.class); mh = MethodHandles.lookup().findVirtual(QuoteCb.class, methodName, desc); cbMethodCache.put(Integer.valueOf(x), mh); } } } return mh; }Copy the code

First, create the type array for the constructor of the ‘QuoteCb’ class. Then create a “BigDecimal” array for the constructor parameters. Then ‘foreach (…). ‘Traverse ‘QuoteCb class getter.

In ‘createGetMethodHandle (…). ‘method, the getter method handle for the constructor argument is returned or created. Method handles are cached in the static ConcurrentHashMap because they are created only once in the synchronized block (the hourly and daily averages are executed simultaneously).

Methods handle is then used to obtain the value of the two Pojos and using the ‘serviceUtils. AvgHourValue (…). The ‘method calculates the average. The value is then stored in the constructor parameter array.

Value access using method handles is very fast. Object creation with so many parameters in the constructor has very little impact on CPU load.

Other Pojos have only one hand full of arguments and use normal constructor calls and getter calls to compute, like QuoteBs’ avgQuote(…) in BitstampService. The ‘method does as follows:

private QuoteBs avgBsQuote(QuoteBs q1, QuoteBs q2, long count) {
   QuoteBs myQuote = new QuoteBs(
      this.serviceUtils.avgHourValue(q1.getHigh(), q2.getHigh(), count),
      this.serviceUtils.avgHourValue(q1.getLast(), q2.getLast(), count),
      q1.getTimestamp(),
      this.serviceUtils.avgHourValue(q1.getBid(), q2.getBid(), count),
      this.serviceUtils.avgHourValue(q1.getVwap(), q2.getVwap(), count),
      this.serviceUtils.avgHourValue(q1.getVolume(), q2.getVolume(), 
         count),
      this.serviceUtils.avgHourValue(q1.getLow(), q2.getLow(), count),
      this.serviceUtils.avgHourValue(q1.getAsk(), q2.getAsk(), count),
      this.serviceUtils.avgHourValue(q1.getOpen(), q2.getOpen(), count));
   myQuote.setCreatedAt(q1.getCreatedAt());
   return myQuote;
}
Copy the code

The implementation of the conclusion

@AsyncSpring Boot supports running @EventListener at application startup with comments “” and” “to easily start average calculations. The ‘@scheduled’ annotation makes it easy to create CRon jobs, and the ‘ ‘annotated ShedLock library @Schedulerlock horizontally extends applications that run CRon/start jobs. The reactive nature of Spring Boot and MongoDb drivers makes it possible to flow DB data from finder to Mapper to insertAll.

Kubernetes Settings extension in Helm Chart

The Minikube Settings for the Kubernetes cluster can be found in minikubesetup.sh. The environment variable “CPU_CONSTRAINT” is set in values.yaml. CPU and memory limits in kubtemplate. yaml have been updated:

Limits: Memory: "3G" CPU: "0.6" Requests: Memory: "1G" CPU: "0.3"Copy the code

For MongoDb deployment.

Limits: Memory: "768M" CPU: "1.4" Requests: Memory: "256M" CPU: "0.5"Copy the code

For AngularAndSpring project deployments, MongoDb never reaches its CPU limits due to these CPU limits.

performance

Average calculations are run nightly using the scheduler, and there are the last few days of data to process. It’s done in seconds or less. The scheduler has its own thread pool and does not interfere with user requests. After recalculating averages of data over 3 years, performance becomes interesting. Bitstamp and Coinbase quotes have different structures, so it is interesting to compare their average computational performance. Both datasets are indexed by date, and all quotes for a day are queried once.

Coinbase data set

The Coinbase POJO has over 150 BigDecimal values for different currencies. One POJO per minute. 1440 Pojos per day.

Bit stamp data set

Bitstamp POJO has eight BigDecimal values for a currency. There are eight POJOs per minute. 11,520 POJOs per day.

The original performance

On a 4-core machine, with enough memory for Jdk and MongoDb, the average daily and hourly calculations can run at roughly the same time as they would run separately.

  • Bitstamp simultaneously Java CpuCore 100-140% MongoDb CpuCore 40-50% 780 seconds.
  • Bitstamp only 60-70% Java CpuCore ~20% MongoDb CpuCore 790 seconds per hour.
  • Coinbase simultaneously Java CpuCore 160-190% MongoDb CpuCore ~10% 1680 seconds.
  • Coinbase only Java CpuCore 90-100% MongoDb CpuCore ~5% 1620 seconds per hour.

Coinbase POJOs with more values seemed to impose more load on the Jdk core, while the large number of PoJOs in the Bitstamp dataset seemed to impose more load on the MongoDb core.

Coinbase Pojo performance bottleneck

The Coinbase import was the slowest, and the profilers showed that the virtual machine was using about 512 MB of free memory and had no memory stress. The G1 garbage collector’s pause time is less than 100 milliseconds, and the memory chart looks fine. CPU time spent by method shows that 60% of the time is spent creating BigDecimal objects and 25% of the time is spent partitioning them. All other values were below 5%. A memory snapshot shows close to the maximum number of 3 million BigDecimal objects in memory (~120 MB). They are collected every few seconds, with no obvious GC pauses.

Conclusion of original performance

MongoDb has no I/O or Cpu and 2 GB cache limits. Due to the large number of creation/calculations of BigDecimal classes, the Jdk is CPU limited in Coinbase calculations. The G1 GC shows no problems. Constructors with more than 150 parameters are not a problem.

Limited resource performance in Kubernetes

To see how it performs under memory and CPU limits, the project and MongoDb run in a Minikube cluster with 1.4 cpucores and 768 Mb of memory in the JDK and 0.6 cpucores and 3 Gb in MongoDb. The average calculation performed slowly as expected, but the Coinbase calculation left the JDK with insufficient CPU resources so that the Shedlock library could not update the database lock in a timely manner (10 seconds). Therefore, in the application. The propertiesCPU_CONSTRAINT checked “, “environment variable to switch from concurrent computing to calculation sequence.

conclusion

Averaging uses too many BigDecimal’s to speed things up. Querying daily quotes for large datasets is also inefficient. Neither bottleneck is a problem in normal operation, and it works fine if you need to fully recalculate the average. The G1 garbage collector performs well. Investigating performance bottlenecks is interesting and provides insights into performance-critical code. It turns out that the performance bottleneck can be in a surprising place, and guesses like 150+ parameter constructors are irrelevant.

Finally, interested to learn the relevant information of friends like three times + attention after the private letter to me