Mengoptimalkan aplikasi Java

Panduan ini menjelaskan pengoptimalan untuk layanan penayangan Knative yang ditulis dalam bahasa pemrograman Java, bersama dengan informasi latar belakang untuk membantu Anda memahami konsekuensi dari beberapa pengoptimalan. Informasi di halaman ini melengkapi tips pengoptimalan umum, yang juga berlaku pada Java.

Aplikasi berbasis web Java tradisional didesain untuk menyajikan permintaan dengan konkurensi tinggi dan latensi rendah, serta cenderung merupakan aplikasi yang berjalan lama. JVM sendiri juga mengoptimalkan kode eksekusi seiring waktu dengan JIT sehingga hot paths dioptimasi dan aplikasi dapat berjalan lebih efisien seiring waktu.

Sebagian besar praktik terbaik dan pengoptimalan dalam aplikasi berbasis web Java tradisional ini berkisar tentang:

  • Menangani permintaan serentak (baik I/O yang berbasis thread maupun yang tidak memblokir)
  • Mengurangi latensi respons menggunakan penggabungan koneksi dan mengelompokkan fungsi yang tidak penting. Misalnya, mengirim trace dan metrik ke tugas latar belakang.

Meskipun banyak pengoptimalan tradisional ini bekerja dengan baik untuk yang berjalan lama, aplikasi tersebut mungkin tidak berfungsi dengan baik dalam layanan Knative , yang hanya berjalan saat melayani permintaan secara aktif. Halaman ini akan membawa Anda melalui beberapa pengoptimalan dan kompromi yang berbeda untuk penayangan Knative yang dapat Anda gunakan untuk mengurangi waktu {i>startup<i} dan penggunaan memori.

Mengoptimalkan gambar penampung

Dengan mengoptimalkan image container, Anda dapat mengurangi waktu pemuatan dan startup. Anda dapat mengoptimalkan image dengan:

  • Meminimalkan image container
  • Menghindari penggunaan JAR dengan arsip library susun bertingkat
  • Menggunakan Jib

Meminimalkan image container

Buka halaman tips umum tentang meminimalkan container untuk mengetahui konteks selengkapnya tentang masalah ini. Halaman tips umum merekomendasikan untuk mengurangi isi image container hanya untuk sesuai kebutuhan. Contoh, pastikan image container Anda tidak berisi :

  • Kode sumber
  • Artefak build Maven
  • Alat pembuat
  • Direktori Git
  • Biner/utilitas yang tidak digunakan

Jika Anda mem-build kode dari dalam Dockerfile, gunakan build multitahap Docker sehingga image container terakhir hanya memiliki JRE dan file JAR aplikasi itu sendiri.

Menghindari JAR arsip library bertingkat

Beberapa framework yang populer, seperti Spring Boot, membuat file arsip aplikasi (JAR) yang berisi file JAR library tambahan (JAR susun bertingkat). File-file ini membutuhkan untuk dibuka/didekompresi selama waktu startup dan dapat meningkatkan kecepatan startup dalam penyaluran Knative. Jika memungkinkan, buat JAR tipis dengan library eksternal: hal ini dapat diotomatiskan dengan menggunakan Jib untuk menyimpan aplikasi Anda dalam container

Menggunakan Jib

Gunakan plugin Jib untuk membuat container minimal dan meratakan arsip aplikasi secara otomatis. Jib berfungsi dengan Maven dan Gradle, serta berfungsi dengan aplikasi Spring Boot yang siap pakai. Beberapa framework aplikasi mungkin memerlukan konfigurasi Jib tambahan.

Pengoptimalan JVM

Mengoptimalkan JVM untuk layanan penyaluran Knative dapat menghasilkan performa dan penggunaan memori.

Menggunakan Versi JVM berbasis container

Di VM dan mesin, untuk alokasi CPU dan memori, JVM memahami CPU dan memori yang dapat digunakan dari lokasi yang sudah diketahui, misalnya, di Linux, /proc/cpuinfo, dan /proc/meminfo. Namun, saat berjalan di dalam container, batasan CPU dan memori disimpan di /proc/cgroups/.... Versi JDK yang lebih lama terus memeriksa di /proc, bukan di /proc/cgroups yang dapat mengakibatkan lebih banyak penggunaan CPU dan memori daripada yang ditetapkan. Hal ini dapat menyebabkan:

  • Jumlah thread yang berlebih karena ukuran pool thread dikonfigurasi oleh Runtime.availableProcessors()
  • Heap maks default yang melebihi batas memori container. JVM secara agresif menggunakan memori sebelum mengumpulkan sampah. Hal ini dapat dengan mudah menyebabkan container tersebut melebihi batas memori container, dan mengalami OOMKilled.

Jadi, gunakan versi JVM yang container aware. Versi OpenJDK yang lebih besar atau sama dengan versi 8u192 secara default container aware.

Memahami Penggunaan Memori JVM

Penggunaan memori JVM terdiri dari penggunaan memori native dan penggunaan heap. Memori kerja aplikasi Anda biasanya berada di dalam heap. Ukuran heap tersebut dibatasi oleh konfigurasi Heap Maks. Dengan Knative yang menyajikan RAM 256 MB Anda tidak dapat menetapkan seluruh 256 MB ke Max Heap, karena JVM dan OS juga memerlukan memori native, misalnya, tumpukan thread, {i>cache<i} kode, menangani file, buffer, dll. Jika aplikasi Anda OOMKilled dan Anda perlu mengetahui penggunaan memori JVM (memori native + heap), aktifkan Native Memory Pelacakan untuk melihat penggunaan setelah berhasil keluar dari aplikasi. Jika aplikasi Anda mengalami OOMKilled, aplikasi tersebut tidak akan dapat mencetak informasi. Dalam hal ini, jalankan aplikasi dengan memori yang lebih banyak terlebih dahulu agar berhasil menghasilkan output.

Native Memory Tracking tidak dapat diaktifkan melalui JAVA_TOOL_OPTIONS variabel lingkungan. Anda perlu menambahkan argumen startup command line Java ke entrypoint image container sehingga aplikasi Anda dimulai dengan argumen ini:

java -XX:NativeMemoryTracking=summary \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintNMTStatistics \
  ...

Penggunaan memori native dapat diperkirakan berdasarkan jumlah class yang akan dimuat. Pertimbangkan untuk menggunakan Java Memory Calculator open source untuk memperkirakan kebutuhan memori.

Menonaktifkan compiler pengoptimalan

Secara default, JVM memiliki beberapa fase kompilasi JIT. Meskipun fase ini meningkatkan efisiensi aplikasi Anda dari waktu ke waktu, fase ini juga dapat menambah overhead pada penggunaan memori dan meningkatkan waktu startup.

Untuk aplikasi serverless yang berjalan singkat (misalnya, fungsi), pertimbangkan untuk menonaktifkan fase pengoptimalan untuk mengorbankan efisiensi jangka panjang demi mengurangi waktu startup.

Untuk layanan penayangan Knative, konfigurasikan variabel lingkungan:

JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

Menggunakan berbagi data class aplikasi

Untuk mengurangi kelebihan waktu JIT dan penggunaan memori, pertimbangkan untuk menggunakan berbagi data class aplikasi (AppCDS) untuk membagikan class Java yang dikompilasi terlebih dahulu sebagai arsip. Arsip AppCDS dapat digunakan kembali saat memulai instance yang lain dari aplikasi Java yang sama. JVM dapat menggunakan kembali data yang telah dihitung sebelumnya dari arsip sehingga mengurangi waktu startup.

Pertimbangan berikut berlaku untuk penggunaan AppCDS:

  • Arsip AppCDS yang akan digunakan kembali harus direproduksi oleh distribusi, versi, dan arsitektur OpenJDK yang sama persis dengan yang awalnya digunakan untuk memproduksinya.
  • Anda harus menjalankan aplikasi Anda minimal sekali untuk membuat daftar class yang akan dibagikan, lalu menggunakan daftar tersebut untuk membuat arsip AppCDS.
  • Cakupan class tersebut bergantung pada codepath yang dieksekusi selama menjalankan aplikasi. Untuk meningkatkan cakupan tersebut, picu lebih banyak codepath secara terprogram.
  • Aplikasi harus berhasil keluar untuk membuat daftar class ini. Sebaiknya terapkan flag aplikasi yang digunakan untuk menunjukkan pembuatan arsip AppCDS agar dapat segera keluar.
  • Arsip AppCDS dapat digunakan kembali jika Anda meluncurkan instance baru dengan cara yang sama persis seperti arsip yang dibuat.
  • Arsip AppCDS hanya berfungsi dengan paket file JAR reguler; Anda tidak bisa menggunakan {i>nested JAR<i}.

Contoh Spring Boot yang menggunakan file JAR terinkorporasi

Aplikasi Spring Boot menggunakan JAR uber bertingkat secara default yang tidak akan berfungsi untuk AppCDS. Jadi, jika menggunakan AppCDS, Anda perlu membuat JAR yang terinkorporasi. Misalnya, menggunakan Maven dan Maven Shade Plugin:

<build>
  <finalName>helloworld</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <configuration>
        <keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope>
        <createDependencyReducedPom>true</createDependencyReducedPom>
        <filters>
          <filter>
            <artifact>*:*</artifact>
            <excludes>
              <exclude>META-INF/*.SF</exclude>
              <exclude>META-INF/*.DSA</exclude>
              <exclude>META-INF/*.RSA</exclude>
            </excludes>
          </filter>
        </filters>
      </configuration>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.handlers</resource>
              </transformer>
              <transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
                <resource>META-INF/spring.factories</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.schemas</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>${mainClass}</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

Jika JAR yang terinkorporasi berisi semua dependensi, Anda dapat memproduksi arsip sederhana selama pembuatan container menggunakan Dockerfile:

# Use Docker's multi-stage build
FROM adoptopenjdk:11-jre-hotspot as APPCDS

COPY target/helloworld.jar /helloworld.jar

# Run the application, but with a custom trigger that exits immediately.
# In this particular example, the application looks for the '--appcds' flag.
# You can implement a similar flag in your own application.
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true

# From the captured list of classes (based on execution coverage),
# generate the AppCDS archive file.
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar

FROM adoptopenjdk:11-jre-hotspot

# Copy both the JAR file and the AppCDS archive file to the runtime container.
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa

# Enable Application Class-Data sharing
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar

Menonaktifkan verifikasi kelas

Ketika JVM memuat class ke dalam memori untuk dieksekusi, JVM akan memverifikasi bahwa class tidak dirusak dan tidak memiliki kerusakan atau hasil edit yang berbahaya. Jika software Anda pipeline delivery tepercaya (misalnya, Anda dapat memverifikasi dan memvalidasi setiap output), jika Anda dapat sepenuhnya memercayai bytecode pada image container dan aplikasi Anda tidak memuat kelas dari sumber jarak jauh yang acak, maka Anda dapat mempertimbangkan menonaktifkan verifikasi. Menonaktifkan verifikasi dapat meningkatkan kecepatan startup jika ada banyak class yang dimuat pada waktu startup.

Untuk layanan penayangan Knative, konfigurasikan variabel lingkungan:

JAVA_TOOL_OPTIONS="-noverify"

Mengurangi ukuran stack thread

Sebagian besar aplikasi web Java berbasis thread per koneksi. Setiap thread Java memakai memori native (bukan di dalam heap). Hal ini dikenal sebagai stack thread dan ditetapkan secara default ke 1 MB per thread. Jika aplikasi Anda menangani 80 permintaan serentak, aplikasi tersebut mungkin memiliki setidaknya 80 thread, yang berarti 80 MB ruang stack thread yang digunakan. Memori tersebut merupakan tambahan untuk ukuran heap. Defaultnya mungkin lebih besar dari yang diperlukan. Anda dapat mengurangi ukuran stack thread.

Jika mengurangi terlalu banyak, Anda akan melihat java.lang.StackOverflowError. Anda dapat membuat profil aplikasi dan menemukan ukuran stack thread yang optimal untuk dikonfigurasi.

Untuk layanan penayangan Knative, konfigurasikan variabel lingkungan:

JAVA_TOOL_OPTIONS="-Xss256k"

Mengurangi thread

Anda dapat mengoptimalkan memori dengan mengurangi jumlah thread, yaitu dengan cara menggunakan strategi reaktif yang tidak memblokir dan menghindari aktivitas latar belakang.

Mengurangi jumlah thread

Setiap thread Java dapat meningkatkan penggunaan memori karena Thread Stack. Penyajian Knative memungkinkan maksimum 80 penayangan permintaan. Dengan model thread-per-koneksi, Anda memerlukan 80 thread untuk menangani semua permintaan serentak. Sebagian besar server web dan framework memungkinkan Anda mengonfigurasi jumlah maksimum thread dan koneksi. Misalnya, di Spring Boot, Anda dapat membatasi koneksi maksimum dalam file applications.properties:

server.tomcat.max-threads=80

Menulis kode reaktif yang tidak memblokir untuk mengoptimalkan memori dan pengaktifan

Untuk benar-benar mengurangi jumlah thread, pertimbangkan untuk mengadopsi model pemrograman reaktif non-pemblokiran sehingga jumlah thread dapat dikurangi secara signifikan saat menangani lebih banyak permintaan serentak. Framework aplikasi seperti Spring Boot dengan Webflux, Micronaut, dan Quarkus mendukung aplikasi web reaktif.

Framework reaktif seperti Spring Boot dengan Webflux, Micronaut, Quarkus umumnya memiliki waktu startup yang lebih cepat.

Jika Anda terus menulis kode pemblokir dalam framework non-pemblokiran, throughput dan tingkat error akan jauh lebih buruk dalam inferensi Knative layanan. Hal ini dikarenakan framework non-pemblokiran hanya akan memiliki beberapa thread, misalnya, 2 atau 4. Jika kode Anda terblokir, kode tersebut hanya dapat menangani sedikit permintaan serentak.

Framework non-pemblokiran ini juga dapat memindahkan kode pemblokir ke pool thread tanpa batas. Artinya, meskipun dapat menerima banyak permintaan serentak, kode pemblokiran akan dieksekusi dalam thread baru. Jika thread terakumulasi secara tidak terbatas, Anda akan menghabiskan resource CPU dan memulai thrash. Latensi akan sangat parah. Jika menggunakan framework non-pemblokiran, pastikan Anda memahami model pool thread dan mengikat pool tersebut dengan sesuai.

Menghindari aktivitas latar belakang

Inferensi Knative men-throttle CPU instance saat instance tidak lagi menerima permintaan. Workload tradisional yang memiliki tugas latar belakang memerlukan pertimbangan khusus saat berjalan di penayangan Knative.

Misalnya, jika Anda mengumpulkan metrik aplikasi dan mengelompokkan metrik di latar belakang untuk mengirim secara berkala, maka metrik tersebut tidak akan mengirimkan CPU di-throttle. Jika aplikasi terus-menerus menerima permintaan, Anda mungkin akan melihat lebih sedikit masalah. Jika aplikasi Anda memiliki QPS yang rendah, tugas di latar belakang mungkin tidak akan pernah dijalankan.

Beberapa pola terkenal dengan latar belakang yang perlu Anda perhatikan:

  • JDBC Connection Pools - pembersihan dan pemeriksaan koneksi biasanya terjadi di latar belakang
  • Distributed Trace Senders - Trace yang terdistribusi biasanya dikelompokkan dan dikirim secara berkala atau saat buffer penuh di latar belakang.
  • Metrics Senders - Metrik biasanya dikelompokkan dan dikirim secara berkala di latar belakang.
  • Untuk Spring Boot, metode apa pun yang dianotasi dengan anotasi @Async
  • Timers - semua pemicu berbasis Timer (misalnya, anotasi ScheduleThreadPoolExecutor, Quartz, atau @Scheduled Spring) mungkin tidak dijalankan saat CPU dibatasi.
  • Message receivers - Misalnya, klien pull streaming Pub/Sub, klien JMS, atau klien Kafka, biasanya berjalan di thread latar belakang tanpa memerlukan permintaan. Hal ini tidak akan berfungsi apabila aplikasi Anda tidak memiliki permintaan. Menerima pesan dengan cara ini tidak direkomendasikan dalam penayangan Knative.

Pengoptimalan aplikasi

Dalam kode layanan penyaluran Knative, Anda juga dapat mengoptimalkan waktu startup, dan penggunaan memori.

Mengurangi tugas startup

Aplikasi berbasis web Java tradisional dapat memiliki banyak tugas untuk diselesaikan selama startup, misalnya, pramuat data, pemanasan cache, pembuatan pool koneksi, dll. Tugas ini menjadi lambat saat dijalankan secara berurutan. Namun, jika ingin dijalankan secara paralel, Anda harus meningkatkan jumlah core CPU.

Penyajian Knative saat ini mengirim permintaan pengguna yang sebenarnya untuk memicu cold start di instance Compute Engine. Pengguna yang memiliki permintaan ditetapkan ke instance yang baru dimulai, mungkin akan mengalami penundaan yang lama. Penyajian Knative saat ini tidak memiliki "kesiapan" untuk menghindari pengiriman permintaan ke aplikasi yang belum dibaca.

Menggunakan penggabungan koneksi

Jika Anda menggunakan pool koneksi, perhatikan bahwa pool koneksi dapat mengeluarkan koneksi yang tidak diperlukan di latar belakang (lihat Menghindari tugas latar belakang). Jika aplikasi Anda memiliki QPS rendah, dan dapat menoleransi latensi tinggi, pertimbangkan untuk membuka dan menutup koneksi per permintaan. Jika aplikasi Anda memiliki QPS tinggi, penghapusan latar belakang dapat terus dijalankan selama ada permintaan aktif.

Dalam kedua kasus tersebut, akses database aplikasi akan terhambat oleh koneksi maksimum yang diizinkan database Hitung koneksi maksimum yang dapat Anda buat per instance penayangan Knative, mengonfigurasi instance maksimum penyaluran Knative sehingga instance maksimum berapa kali koneksi per instance kurang dari jumlah maksimum koneksi diizinkan.

Menggunakan Spring Boot

Jika menggunakan Spring Boot, Anda perlu mempertimbangkan pengoptimalan berikut

Menggunakan Spring Boot versi 2.2 atau yang lebih baru

Sejak versi 2.2, Spring Boot telah sangat dioptimalkan untuk kecepatan startup. Jika Anda menggunakan versi Spring Boot di bawah 2.2, sebaiknya upgrade atau terapkan pengoptimalan satu per satu secara manual.

Menggunakan inisialisasi lambat

Ada flag inisialisasi lambat global yang dapat diaktifkan di Spring Boot 2.2 dan yang lebih baru. Tindakan ini akan meningkatkan kecepatan startup, tetapi dengan kompromi, permintaan pertama mungkin memiliki latensi yang lebih lama karena harus menunggu komponen melakukan inisialisasi untuk pertama kalinya.

Anda dapat mengaktifkan inisialisasi lambat di application.properties:

spring.main.lazy-initialization=true

Atau dengan menggunakan variabel lingkungan:

SPRING_MAIN_LAZY_INITIALIZATIION=true

Namun, jika Anda menggunakan instance minimal, inisialisasi berdasarkan permintaan tidak akan membantu karena inisialisasi seharusnya terjadi saat instance minimal dimulai.

Menghindari pemindaian kelas

Pemindaian kelas akan menyebabkan pembacaan {i>disk<i} tambahan dalam penyaluran Knative karena dalam penyaluran Knative, akses {i>disk<i} umumnya lebih lambat daripada komputer biasa. Pastikan Component Scan dibatasi atau sepenuhnya dihindari. Pertimbangkan untuk menggunakan Spring Context Indexer, guna membuat indeks terlebih dahulu. Apakah hal ini akan meningkatkan kecepatan startup tergantung aplikasi Anda.

Misalnya, di pom.xml Maven Anda, tambahkan dependensi pengindeks (sebenarnya hal ini adalah pemroses anotasi):

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-indexer</artifactId>
  <optional>true</optional>
</dependency>

Menggunakan alat developer Spring Boot yang tidak dalam produksi

Jika Anda menggunakan Alat Developer Spring Boot selama pengembangan, pastikan paket tidak dikemas dalam container produksi gambar. Hal ini dapat terjadi jika Anda membangun aplikasi Spring Boot tanpa plugin build Spring Boot (misalnya, menggunakan plugin Shade, atau menggunakan Jib untuk menyimpan).

Dalam kasus ini, pastikan alat build mengecualikan alat Spring Boot Dev secara eksplisit. Atau, menonaktifkan Spring Boot Developer Tool secara eksplisit.

Langkah selanjutnya

Untuk tips lainnya, lihat