【Kafka】Kafka-Server-start.sh 啟動指令碼分析(Ver 2.7.2)

Xander發表於2023-03-02

Kafka-Server-start.sh

  
if [ $# -lt 1 ];  
then  
 # 提示命令使用方法
 echo "USAGE: $0 [-daemon] server.properties [--override property=value]*" exit 1  
fi  
base_dir=$(dirname $0)  
  
if [ "x$KAFKA_LOG4J_OPTS" = "x" ]; then  
    export KAFKA_LOG4J_OPTS="-Dlog4j.configuration=file:$base_dir/../config/log4j.properties"  
fi  
  

if [ "x$KAFKA_HEAP_OPTS" = "x" ]; then  
    export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G"  
fi  
  
EXTRA_ARGS=${EXTRA_ARGS-'-name kafkaServer -loggc'}  
  
COMMAND=$1  
case $COMMAND in  
  -daemon)  
    EXTRA_ARGS="-daemon "$EXTRA_ARGS  
    shift  
    ;;  
  *)  
    ;;  
esac  
  
exec $base_dir/kafka-run-class.sh $EXTRA_ARGS kafka.Kafka "$@"
  1. 判斷引數有沒有,引數個數小於1就提示用法;
  2. 獲取指令碼當前路徑賦值給變數 base_dir;
  3. 判斷日誌引數 KAFKA_LOG4J_OPTS 是否為空,為空就給它一個值;
  4. 判斷堆引數 KAFKA_HEAP_OPTS是否為空,為空就預設給它賦值為 "-Xmx1G -Xms1G",預設的堆空間指定為1G;
  5. 判斷啟動命令中第一個引數是否為 -daemon,如果是就以守護程式啟動(其實不是,是賦給另一個變數 EXTRA_ARGS);
  6. 執行命令。

最後一個指令碼是執行另一個指令碼:kafka-run-class.sh,這個指令碼的內容比較複雜了。

kafka-run-class.sh

#!/bin/bash
if [ $# -lt 1 ];
then
  echo "USAGE: $0 [-daemon] [-name servicename] [-loggc] classname [opts]"
  exit 1
fi

# CYGWIN == 1 if Cygwin is detected, else 0.
if [[ $(uname -a) =~ "CYGWIN" ]]; then
  CYGWIN=1
else
  CYGWIN=0
fi

if [ -z "$INCLUDE_TEST_JARS" ]; then
  INCLUDE_TEST_JARS=false
fi

# Exclude jars not necessary for running commands.
regex="(-(test|test-sources|src|scaladoc|javadoc)\.jar|jar.asc)$"
should_include_file() {
  if [ "$INCLUDE_TEST_JARS" = true ]; then
    return 0
  fi
  file=$1
  if [ -z "$(echo "$file" | egrep "$regex")" ] ; then
    return 0
  else
    return 1
  fi
}

base_dir=$(dirname $0)/..

if [ -z "$SCALA_VERSION" ]; then
  SCALA_VERSION=2.13.3
  if [[ -f "$base_dir/gradle.properties" ]]; then
    SCALA_VERSION=`grep "^scalaVersion=" "$base_dir/gradle.properties" | cut -d= -f 2`
  fi
fi

if [ -z "$SCALA_BINARY_VERSION" ]; then
  SCALA_BINARY_VERSION=$(echo $SCALA_VERSION | cut -f 1-2 -d '.')
fi

# run ./gradlew copyDependantLibs to get all dependant jars in a local dir
shopt -s nullglob
if [ -z "$UPGRADE_KAFKA_STREAMS_TEST_VERSION" ]; then
  for dir in "$base_dir"/core/build/dependant-libs-${SCALA_VERSION}*;
  do
    CLASSPATH="$CLASSPATH:$dir/*"
  done
fi

for file in "$base_dir"/examples/build/libs/kafka-examples*.jar;
do
  if should_include_file "$file"; then
    CLASSPATH="$CLASSPATH":"$file"
  fi
done

if [ -z "$UPGRADE_KAFKA_STREAMS_TEST_VERSION" ]; then
  clients_lib_dir=$(dirname $0)/../clients/build/libs
  streams_lib_dir=$(dirname $0)/../streams/build/libs
  streams_dependant_clients_lib_dir=$(dirname $0)/../streams/build/dependant-libs-${SCALA_VERSION}
else
  clients_lib_dir=/opt/kafka-$UPGRADE_KAFKA_STREAMS_TEST_VERSION/libs
  streams_lib_dir=$clients_lib_dir
  streams_dependant_clients_lib_dir=$streams_lib_dir
fi


for file in "$clients_lib_dir"/kafka-clients*.jar;
do
  if should_include_file "$file"; then
    CLASSPATH="$CLASSPATH":"$file"
  fi
done

for file in "$streams_lib_dir"/kafka-streams*.jar;
do
  if should_include_file "$file"; then
    CLASSPATH="$CLASSPATH":"$file"
  fi
done

if [ -z "$UPGRADE_KAFKA_STREAMS_TEST_VERSION" ]; then
  for file in "$base_dir"/streams/examples/build/libs/kafka-streams-examples*.jar;
  do
    if should_include_file "$file"; then
      CLASSPATH="$CLASSPATH":"$file"
    fi
  done
else
  VERSION_NO_DOTS=`echo $UPGRADE_KAFKA_STREAMS_TEST_VERSION | sed 's/\.//g'`
  SHORT_VERSION_NO_DOTS=${VERSION_NO_DOTS:0:((${#VERSION_NO_DOTS} - 1))} # remove last char, ie, bug-fix number
  for file in "$base_dir"/streams/upgrade-system-tests-$SHORT_VERSION_NO_DOTS/build/libs/kafka-streams-upgrade-system-tests*.jar;
  do
    if should_include_file "$file"; then
      CLASSPATH="$file":"$CLASSPATH"
    fi
  done
  if [ "$SHORT_VERSION_NO_DOTS" = "0100" ]; then
    CLASSPATH="/opt/kafka-$UPGRADE_KAFKA_STREAMS_TEST_VERSION/libs/zkclient-0.8.jar":"$CLASSPATH"
    CLASSPATH="/opt/kafka-$UPGRADE_KAFKA_STREAMS_TEST_VERSION/libs/zookeeper-3.4.6.jar":"$CLASSPATH"
  fi
  if [ "$SHORT_VERSION_NO_DOTS" = "0101" ]; then
    CLASSPATH="/opt/kafka-$UPGRADE_KAFKA_STREAMS_TEST_VERSION/libs/zkclient-0.9.jar":"$CLASSPATH"
    CLASSPATH="/opt/kafka-$UPGRADE_KAFKA_STREAMS_TEST_VERSION/libs/zookeeper-3.4.8.jar":"$CLASSPATH"
  fi
fi

for file in "$streams_dependant_clients_lib_dir"/rocksdb*.jar;
do
  CLASSPATH="$CLASSPATH":"$file"
done

for file in "$streams_dependant_clients_lib_dir"/*hamcrest*.jar;
do
  CLASSPATH="$CLASSPATH":"$file"
done

for file in "$base_dir"/tools/build/libs/kafka-tools*.jar;
do
  if should_include_file "$file"; then
    CLASSPATH="$CLASSPATH":"$file"
  fi
done

for dir in "$base_dir"/tools/build/dependant-libs-${SCALA_VERSION}*;
do
  CLASSPATH="$CLASSPATH:$dir/*"
done

for cc_pkg in "api" "transforms" "runtime" "file" "mirror" "mirror-client" "json" "tools" "basic-auth-extension"
do
  for file in "$base_dir"/connect/${cc_pkg}/build/libs/connect-${cc_pkg}*.jar;
  do
    if should_include_file "$file"; then
      CLASSPATH="$CLASSPATH":"$file"
    fi
  done
  if [ -d "$base_dir/connect/${cc_pkg}/build/dependant-libs" ] ; then
    CLASSPATH="$CLASSPATH:$base_dir/connect/${cc_pkg}/build/dependant-libs/*"
  fi
done

# classpath addition for release
for file in "$base_dir"/libs/*;
do
  if should_include_file "$file"; then
    CLASSPATH="$CLASSPATH":"$file"
  fi
done

for file in "$base_dir"/core/build/libs/kafka_${SCALA_BINARY_VERSION}*.jar;
do
  if should_include_file "$file"; then
    CLASSPATH="$CLASSPATH":"$file"
  fi
done
shopt -u nullglob

if [ -z "$CLASSPATH" ] ; then
  echo "Classpath is empty. Please build the project first e.g. by running './gradlew jar -PscalaVersion=$SCALA_VERSION'"
  exit 1
fi

# JMX settings
if [ -z "$KAFKA_JMX_OPTS" ]; then
  KAFKA_JMX_OPTS="-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.authenticate=false  -Dcom.sun.management.jmxremote.ssl=false "
fi

# JMX port to use
if [  $JMX_PORT ]; then
  KAFKA_JMX_OPTS="$KAFKA_JMX_OPTS -Dcom.sun.management.jmxremote.port=$JMX_PORT "
fi

# Log directory to use
if [ "x$LOG_DIR" = "x" ]; then
  LOG_DIR="$base_dir/logs"
fi

# Log4j settings
if [ -z "$KAFKA_LOG4J_OPTS" ]; then
  # Log to console. This is a tool.
  LOG4J_DIR="$base_dir/config/tools-log4j.properties"
  # If Cygwin is detected, LOG4J_DIR is converted to Windows format.
  (( CYGWIN )) && LOG4J_DIR=$(cygpath --path --mixed "${LOG4J_DIR}")
  KAFKA_LOG4J_OPTS="-Dlog4j.configuration=file:${LOG4J_DIR}"
else
  # create logs directory
  if [ ! -d "$LOG_DIR" ]; then
    mkdir -p "$LOG_DIR"
  fi
fi

# If Cygwin is detected, LOG_DIR is converted to Windows format.
(( CYGWIN )) && LOG_DIR=$(cygpath --path --mixed "${LOG_DIR}")
KAFKA_LOG4J_OPTS="-Dkafka.logs.dir=$LOG_DIR $KAFKA_LOG4J_OPTS"

# Generic jvm settings you want to add
if [ -z "$KAFKA_OPTS" ]; then
  KAFKA_OPTS=""
fi

# Set Debug options if enabled
if [ "x$KAFKA_DEBUG" != "x" ]; then

    # Use default ports
    DEFAULT_JAVA_DEBUG_PORT="5005"

    if [ -z "$JAVA_DEBUG_PORT" ]; then
        JAVA_DEBUG_PORT="$DEFAULT_JAVA_DEBUG_PORT"
    fi

    # Use the defaults if JAVA_DEBUG_OPTS was not set
    DEFAULT_JAVA_DEBUG_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=${DEBUG_SUSPEND_FLAG:-n},address=$JAVA_DEBUG_PORT"
    if [ -z "$JAVA_DEBUG_OPTS" ]; then
        JAVA_DEBUG_OPTS="$DEFAULT_JAVA_DEBUG_OPTS"
    fi

    echo "Enabling Java debug options: $JAVA_DEBUG_OPTS"
    KAFKA_OPTS="$JAVA_DEBUG_OPTS $KAFKA_OPTS"
fi

# Which java to use
if [ -z "$JAVA_HOME" ]; then
  JAVA="java"
else
  JAVA="$JAVA_HOME/bin/java"
fi

# Memory options
if [ -z "$KAFKA_HEAP_OPTS" ]; then
  KAFKA_HEAP_OPTS="-Xmx256M"
fi

# JVM performance options
# MaxInlineLevel=15 is the default since JDK 14 and can be removed once older JDKs are no longer supported
if [ -z "$KAFKA_JVM_PERFORMANCE_OPTS" ]; then
  KAFKA_JVM_PERFORMANCE_OPTS="-server -XX:+UseG1GC -XX:MaxGCPauseMillis=20 -XX:InitiatingHeapOccupancyPercent=35 -XX:+ExplicitGCInvokesConcurrent -XX:MaxInlineLevel=15 -Djava.awt.headless=true"
fi

while [ $# -gt 0 ]; do
  COMMAND=$1
  case $COMMAND in
    -name)
      DAEMON_NAME=$2
      CONSOLE_OUTPUT_FILE=$LOG_DIR/$DAEMON_NAME.out
      shift 2
      ;;
    -loggc)
      if [ -z "$KAFKA_GC_LOG_OPTS" ]; then
        GC_LOG_ENABLED="true"
      fi
      shift
      ;;
    -daemon)
      DAEMON_MODE="true"
      shift
      ;;
    *)
      break
      ;;
  esac
done

# GC options    
GC_FILE_SUFFIX='-gc.log'
GC_LOG_FILE_NAME=''
if [ "x$GC_LOG_ENABLED" = "xtrue" ]; then
  GC_LOG_FILE_NAME=$DAEMON_NAME$GC_FILE_SUFFIX

  # The first segment of the version number, which is '1' for releases before Java 9
  # it then becomes '9', '10', ...
  # Some examples of the first line of `java --version`:
  # 8 -> java version "1.8.0_152"
  # 9.0.4 -> java version "9.0.4"
  # 10 -> java version "10" 2018-03-20
  # 10.0.1 -> java version "10.0.1" 2018-04-17
  # We need to match to the end of the line to prevent sed from printing the characters that do not match
  JAVA_MAJOR_VERSION=$("$JAVA" -version 2>&1 | sed -E -n 's/.* version "([0-9]*).*$/\1/p')
  if [[ "$JAVA_MAJOR_VERSION" -ge "9" ]] ; then
    KAFKA_GC_LOG_OPTS="-Xlog:gc*:file=$LOG_DIR/$GC_LOG_FILE_NAME:time,tags:filecount=10,filesize=100M"
  else
    KAFKA_GC_LOG_OPTS="-Xloggc:$LOG_DIR/$GC_LOG_FILE_NAME -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M"
  fi
fi

# Remove a possible colon prefix from the classpath (happens at lines like `CLASSPATH="$CLASSPATH:$file"` when CLASSPATH is blank)
# Syntax used on the right side is native Bash string manipulation; for more details see
# http://tldp.org/LDP/abs/html/string-manipulation.html, specifically the section titled "Substring Removal"
CLASSPATH=${CLASSPATH#:}

# If Cygwin is detected, classpath is converted to Windows format.
(( CYGWIN )) && CLASSPATH=$(cygpath --path --mixed "${CLASSPATH}")

# Launch mode
if [ "x$DAEMON_MODE" = "xtrue" ]; then
  nohup "$JAVA" $KAFKA_HEAP_OPTS $KAFKA_JVM_PERFORMANCE_OPTS $KAFKA_GC_LOG_OPTS $KAFKA_JMX_OPTS $KAFKA_LOG4J_OPTS -cp "$CLASSPATH" $KAFKA_OPTS "$@" > "$CONSOLE_OUTPUT_FILE" 2>&1 < /dev/null &
else
  exec "$JAVA" $KAFKA_HEAP_OPTS $KAFKA_JVM_PERFORMANCE_OPTS $KAFKA_GC_LOG_OPTS $KAFKA_JMX_OPTS $KAFKA_LOG4J_OPTS -cp "$CLASSPATH" $KAFKA_OPTS "$@"
fi

指令碼內容很長,但是實際上只有最後一部分才是真正在完成啟動操作:

# Launch mode
if [ "x$DAEMON_MODE" = "xtrue" ]; then
  nohup "$JAVA" $KAFKA_HEAP_OPTS $KAFKA_JVM_PERFORMANCE_OPTS $KAFKA_GC_LOG_OPTS $KAFKA_JMX_OPTS $KAFKA_LOG4J_OPTS -cp "$CLASSPATH" $KAFKA_OPTS "$@" > "$CONSOLE_OUTPUT_FILE" 2>&1 < /dev/null &
else
  exec "$JAVA" $KAFKA_HEAP_OPTS $KAFKA_JVM_PERFORMANCE_OPTS $KAFKA_GC_LOG_OPTS $KAFKA_JMX_OPTS $KAFKA_LOG4J_OPTS -cp "$CLASSPATH" $KAFKA_OPTS "$@"
fi

Launch modes

在指令碼最後一段是有關啟動方式的提示。

# Launch mode
if [ "x$DAEMON_MODE" = "xtrue" ]; then
  nohup "$JAVA" $KAFKA_HEAP_OPTS $KAFKA_JVM_PERFORMANCE_OPTS $KAFKA_GC_LOG_OPTS $KAFKA_JMX_OPTS $KAFKA_LOG4J_OPTS -cp "$CLASSPATH" $KAFKA_OPTS "$@" > "$CONSOLE_OUTPUT_FILE" 2>&1 < /dev/null &
else
  exec "$JAVA" $KAFKA_HEAP_OPTS $KAFKA_JVM_PERFORMANCE_OPTS $KAFKA_GC_LOG_OPTS $KAFKA_JMX_OPTS $KAFKA_LOG4J_OPTS -cp "$CLASSPATH" $KAFKA_OPTS "$@"
fi

這段指令碼說明了之前的一大堆指令碼都是為了這裡啟動賦值進行的一系列操作,這裡根據傳遞引數判斷是否守護程式的方式啟動。這裡以使用比較多的 守護程式啟動方式進行引數介紹(實際上兩者差別不算很大)。

KAFKA_HEAP_OPTS

KAFKA_HEAP_OPTS 出自最開頭,判斷堆引數 KAFKA_HEAP_OPTS是否為空,為空就預設給它賦值為 "-Xmx1G -Xms1G"。

KAFKA_JVM_PERFORMANCE_OPTS

這個值代表了JVM的啟動引數。

# JVM performance options
# MaxInlineLevel=15 is the default since JDK 14 and can be removed once older JDKs are no longer supported
# MaxInlineLevel=15 是自JDK 14以來的預設值,一旦舊的JDK不再支援,就可以刪除。
if [ -z "$KAFKA_JVM_PERFORMANCE_OPTS" ]; then
  KAFKA_JVM_PERFORMANCE_OPTS="-server -XX:+UseG1GC -XX:MaxGCPauseMillis=20 -XX:InitiatingHeapOccupancyPercent=35 -XX:+ExplicitGCInvokesConcurrent -XX:MaxInlineLevel=15 -Djava.awt.headless=true"
fi

while [ $# -gt 0 ]; do
  COMMAND=$1
  case $COMMAND in
    -name)
      DAEMON_NAME=$2
      CONSOLE_OUTPUT_FILE=$LOG_DIR/$DAEMON_NAME.out
      shift 2
      ;;
    -loggc)
      if [ -z "$KAFKA_GC_LOG_OPTS" ]; then
        GC_LOG_ENABLED="true"
      fi
      shift
      ;;
    -daemon)
      DAEMON_MODE="true"
      shift
      ;;
    *)
      break
      ;;
  esac
done

G1垃圾收集器

Kafka預設使用G1的垃圾收集器,本身最低JDK版本要求就是JDK1.8。

-server -XX:+UseG1GC -XX:MaxGCPauseMillis=20 -XX:InitiatingHeapOccupancyPercent=35 -XX:+ExplicitGCInvokesConcurrent -XX:MaxInlineLevel=15 -Djava.awt.headless=true

MaxGCPauseMillis

-XX:MaxGCPauseMillis:GC最大的停頓毫秒數,暫停時間預設值200ms,如果設定比這個小的值,G1收集器會盡可能的達到這個預期設定。

因為Kafka是非常激進的高併發分散式訊息佇列,為了獲取更高的併發,使用20ms的極限值值儘可能的減少GC時間,最後用極短GC的代價換取高吞吐,當然結果會導致垃圾回收不乾淨。

但Kafka對於JVM本身的堆記憶體佔用並不是很多,預設20ms的停頓時間其實是可以放心使用的。

此外從Kafka的設計來看,更頻繁的GC是為了儘可能的觸發Full Gc,因為Full Gc是回收Direct Memory的條件,而Kafka大量使用了頁快取提高資料的Log的讀寫速度,底層用的也是Java的Direct Memory。

InitiatingHeapOccupancyPercent

這個引數實際上出入比較大,根據原始碼分析在JDK8b12版本之後,以及JDK11 之前這個引數和官方的文件描述,這個值的含義是符合“整堆”來計算是否觸發Mixed Gc,但是JDK8b12版本之後更高的補丁,以及JDK11之後就變了,它變成根據老年代佔整堆的比重

這樣的出入問題源自此引數的原始碼BUG,這部分涉及原始碼的探討就不討論了,具體可以看關於G1收集器引數InitiatingHeapOccupancyPercent的正確認知 - 豆大俠的菜地 (doudaxia.club)這篇大佬的文章分析。

這裡直接給出結論:

  • 如果你使用的JDK版本在8b12之前,XX:InitiatingHeapOccupancyPercent是整個堆使用量與堆總體容量的比值;
  • 如果你使用的JDK版本在8b12之後(包括大版本9、10、11....),那麼XX:InitiatingHeapOccupancyPercent老年代大小與堆總體容量的比值這種說法和修改之後的JVM原始碼符合。

整體算是一個隱藏已久的BUG,因為G1的垃圾收集器設計角度看,它更關心的是Old Region佔滿整個堆空間之前提前儘可能的進行回收,而不是簡單的看看剩餘空間在整個堆空間的佔比,因為剩餘空間不是一個十分可靠的衡量值。

為了驗證上文大佬的說法,個人也去參閱JDK8的Oracle文件:java (oracle.com)

-XX:c=percent
    Sets the percentage of the heap occupancy (0 to 100) at which to start a concurrent GC cycle. It is used by garbage collectors that trigger a concurrent GC cycle based on the occupancy of the entire heap, not just one of the generations (for example, the G1 garbage collector).

    By default, the initiating value is set to 45%. A value of 0 implies nonstop GC cycles. The following example shows how to set the initiating heap occupancy to 75%:

    -XX:InitiatingHeapOccupancyPercent=75

關鍵字 entire heap,也就是簡單的剩餘空間和整堆的佔比。這裡同樣接著翻閱了一下,直到JDK12版本,這個描述還是和JDK8的版本一致的。直到閱讀長期支援的JDK17的文件,發現裡面的說法終於變了:

Garbage-First (G1) Garbage Collector (oracle.com

XX:InitiatingHeapOccupancyPercent determines the initial value as a percentage of the size of the current old generation as long as there aren't enough observations to make a good prediction of the Initiating Heap Occupancy threshold. Turn off this behavior of G1 using the option-XX:-G1UseAdaptiveIHOP. In this case, the value of -XX:InitiatingHeapOccupancyPercent always determines this threshold.。
“XX:啟動堆佔用百分比”將初始值確定為當前老一代大小的百分比,只要沒有足夠的觀測值來很好地預測起始堆佔用閾值。
使用選項'-XX:-G1UseAdaptiveIHOP'關閉G1的此行為。在這種情況下-XX:InitiatingHeapOccupancyPercent 啟動堆佔用百分比'的值始終確定此閾值。

所以這個值的真實含義和使用的JDK版本有關,並且JDK8的後續補丁版本也修復了這個問題,所以最終建議是升級JDK8的補丁版本,或者使用JDK11之後的版本。

-XX:+ExplicitGCInvokesConcurrent

看似簡單的引數,實際上又是隱藏這非常多的“坑”和細節,這裡我們劃分更多的小節慢慢細品。

簡單理解

這個引數是指透過使用System.gc()請求啟用併發 GC 的呼叫預設禁用。如果沒有特殊的應用場景,大部分情況下這個引數都是被建議禁用的,而併發GC實際上就是CMS的併發回收處理。

個人在官方文件中搜到類似的引數描述:Garbage-First Garbage Collector Tuning (oracle.com)

Other causes than Allocation Failure for a Full GC typically indicate that either the application or some external tool causes a full heap collection. If the cause is , and there is no way to modify the application sources, the effect of Full GCs can be mitigated by using or let the VM completely ignore them by setting . External tools may still force Full GCs; they can be removed only by not requesting them.System.gc()-XX:+ExplicitGCInvokesConcurrent -XX:+DisableExplicitGC

上面一大段的話大意指的是:阻止外部呼叫Full GC(也就是System.gc())要麼直接設定-XX:+DisableExplicitGC,要麼設定-XX:+ExplicitGCInvokesConcurrent提高強制Full Gc的效率,閱讀原始碼發現這兩個參數不能一起開啟,因為-XX:+ExplicitGCInvokesConcurrent需要關閉-XX:+DisableExplicitGC引數才能生效。

部分文章也解釋僅僅建議在G1的垃圾收集器中可以使用-XX:+ExplicitGCInvokesConcurrent。其他垃圾收集器不建議使用。
Kafka官方修復BUG:-XX:+DisableExplicitGC 改為 -XX:+ExplicitGCInvokesConcurrent

為什麼兩者只能選其一使用,JDK 8 的JVM中存在類似的程式碼可以給予解釋。

bool GenCollectedHeap::should_do_concurrent_full_gc(GCCause::Cause cause) {
    // 檢查引數 -XX:+DisableExplicitGC 和 -XX:+ExplicitGCInvokesConcurrent
  return UseConcMarkSweepGC &&
         ((cause == GCCause::_gc_locker && GCLockerInvokesConcurrent) ||
         // -XX:+ExplicitGCInvokesConcurrent 需要滿足不配置-XX:+DisableExplicitGC的條件,才能判定為true
          (cause == GCCause::_java_lang_system_gc && ExplicitGCInvokesConcurrent));
}

void GenCollectedHeap::collect(GCCause::Cause cause) {
    // 檢查引數 -XX:+DisableExplicitGC 和 -XX:+ExplicitGCInvokesConcurrent
  if (should_do_concurrent_full_gc(cause)) {
#ifndef SERIALGC
    // mostly concurrent full collection
    collect_mostly_concurrent(cause);
#else  // SERIALGC
    ShouldNotReachHere();
#endif // SERIALGC
  } else {
#ifdef ASSERT
    if (cause == GCCause::_scavenge_alot) {
      // minor collection only
      collect(cause, 0);
    } else {
      // Stop-the-world full collection
      // STW 進行Full Gc
      collect(cause, n_gens() - 1);
    }
#else
    // Stop-the-world full collection
    collect(cause, n_gens() - 1);
#endif
  }
}

collect裡一開頭就有個判斷,如果should_do_concurrent_full_gc返回true,那會執行collect_mostly_concurrent做並行的回收。

回到Kafka的服務端引數,KafKa最初的服務端啟動指令碼中,此引數實際為-XX:+DisableExplicitGC,但是後續被指出會影響直接記憶體的回收效能,並且很可能會導致直接記憶體無法被回收!

為什麼會有這麼嚴重 ? 這裡先不急著分析,而是先看看作者的這個issue的提交:

KAFKA-5470: Replace -XX:+DisableExplicitGC with -XX:+ExplicitGCInvokesConcurrent in kafka-run-class by ijuma · Pull Request #3371 · apache/kafka (github.com)

提交者的原話是:

This is important because Bits.reserveMemory calls System.gc() hoping to free native
memory in order to avoid throwing an OutOfMemoryException. This call is currently
a no-op due to -XX:+DisableExplicitGC.

It's worth mentioning that -XX:MaxDirectMemorySize can be used to increase the
amount of native memory available for allocation of direct byte buffers.

簡單來說就是Bits.reserveMemory裡面會有System.gc()呼叫,透過程式強制呼叫Full Gc來回收掉native記憶體,所以建議在JVM引數中刪掉-XX:+DisableExplicitGC,開啟System.gc();並且透過新增-XX:+ExplicitGCInvokesConcurrentSystem.gc()呼叫效率更高一些。

另外大佬這裡還提了一嘴-XX:MaxDirectMemorySize可以用來提高可用於分配直接位元組緩衝區的本地記憶體的數量。
大佬一句話就是一個知識點,牛呀。
Bits#reserveMemory

既然提交者提到了Bits#reserveMemory,這裡就順帶貼一下官方jdk8的java.nio.Bits#reserveMemory原始碼方便理解:

// These methods should be called whenever direct memory is allocated or
// freed.  They allow the user to control the amount of direct memory
// which a process may access.  All sizes are specified in bytes.

//每當分配直接記憶體或釋放。 它們允許使用者控制直接記憶體的數量程式可以訪問的內容。 所有大小均以位元組為單位指定。

static void reserveMemory(long size, int cap) {

    if (!memoryLimitSet && VM.isBooted()) {
        maxMemory = VM.maxDirectMemory();
        memoryLimitSet = true;
    }

    // optimist!
    if (tryReserveMemory(size, cap)) {
        return;
    }

    final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

    // retry while helping enqueue pending Reference objects
    // which includes executing pending Cleaner(s) which includes
    // Cleaner(s) that free direct buffer memory
    while (jlra.tryHandlePendingReference()) {
        if (tryReserveMemory(size, cap)) {
            return;
        }
    }

    // trigger VM's Reference processing
    System.gc();

    // a retry loop with exponential back-off delays
    // (this gives VM some time to do it's job)
    boolean interrupted = false;
    try {
        long sleepTime = 1;
        int sleeps = 0;
        while (true) {
            if (tryReserveMemory(size, cap)) {
                return;
            }
            if (sleeps >= MAX_SLEEPS) {
                break;
            }
            if (!jlra.tryHandlePendingReference()) {
                try {
                    Thread.sleep(sleepTime);
                    sleepTime <<= 1;
                    sleeps++;
                } catch (InterruptedException e) {
                    interrupted = true;
                }
            }
        }

        // no luck
        throw new OutOfMemoryError("Direct buffer memory");

    } finally {
        if (interrupted) {
            // don't swallow interrupts
            Thread.currentThread().interrupt();
        }
    }
}

我們透過閱讀JDK8的Nio包的這部分用於分配DirectMememory的一段程式碼,發現每次Direct Mememory進行實際的分配動作之前,都會呼叫這個方法檢測是否有足夠空間分配時都被呼叫,不過裡面的邏輯奇奇怪怪的,初看確實有點摸不著頭腦。

國外有網友直接痛罵了這一段程式碼是一坨Shit:java.nio.Bits.reserveMemory uses a lock, calls System.gc, and is generally bad code... (google.com)

1.  ALL memory access requires a lock.  That's evil if you're allocating small chunks.

2.  The code to change the reserved memory counters is duplicated twice.  This is a great way to introduce bugs.  (how did this even get approved? do they not do code audits or require that commits be approved?)

3.  If you are out of memory we call System.gc... EVIL.  The entire way direct memory is reclaimed via GC is a horrible design.

4.  After GC they sleep 100ms.  What's that about?  Why 100ms?  Why not 1ms?  
  1. 所有的記憶體訪問都需要一個鎖。 如果你分配的是小塊的記憶體簡直就是噩夢。
  2. 改變保留記憶體計數器的程式碼重複了兩次。 這是個引入錯誤的好方法。 (難道他們不進行程式碼審計或要求提交的程式碼必須得到批准嗎?)
  3. 如果你沒有記憶體了,我們就呼叫System.gc... 透過GC回收直接記憶體的整個方式是一個可怕的設計。
  4. 在GC之後,他們會休眠100ms。 那是什麼意思? 為什麼是100ms? 為什麼不是1ms?

個人並不感冒這些評論,這裡拎出System.gc()這行程式碼來分析具體意圖。要看懂這一行程式碼的意圖,我們需要了解DirectMemory關聯的本機記憶體是如何清理的,這裡就直接給出答案了。

JVM實際上是管不到DirectMemory的,需要依靠特殊的方式回收掉DirectMemory:

  • 手動呼叫unsafe.freeMemory()進行釋放,nettyByteBuf.release()就是這種方式實現的;
  • 利用GC機制在GC的過程中自動呼叫unsafe.freeMemory()釋放被引用的直接記憶體;

這段程式碼作者的意圖明顯是顯示呼叫System.gc(),儘可能回收不可達的DirectByteBuffer物件,也只有透過GC才會自動觸發unsafe.freeMemory()的呼叫,釋放直接記憶體。

至於其他程式碼.....這裡不做過多評論。

Fix -XX:+DisableExplicitGC

基於以上種種原因,Kafka官方最終提交了一個Commit修復這個問題:

Fix run class to work with Java 10 and use ExplicitGCInvokesConcurrent by ijuma · Pull Request #1329 · confluentinc/ksql (github.com)

具體的調整細節可以看下面的連線,讀者可以透過對比自己的下載Kafka啟動指令碼檢視是否修復這個問題:

KAFKA-5470: Replace -XX:+DisableExplicitGC with -XX:+ExplicitGCInvokesConcurrent in kafka-run-class by ijuma · Pull Request #3371 · apache/kafka (github.com)

其他參考資料

下面的這些參考資料可以幫助我們更深入的理解-XX:+ExplicitGCInvokesConcurrent引數附帶的知識點:

-XX:+ExplicitGCInvokesConcurrent的含義:What is JVM startup parameter: -XX:+ExplicitGCInvokesConcurrent? - yCrash Answers

官方的G1文件:Java HotSpot Garbage Collection (oracle.com)

為什麼僅限G1可以開啟此引數來進行健康檢查,其他垃圾收集器建議關閉此引數:Health Check: Explicit Garbage Collection | Jira | Atlassian Documentation

JVM原始碼分析之SystemGC完全解讀 | HeapDump效能社群

  • 為什麼不能同時設定-XX:+DisableExplicitGC 以及 -XX:+ExplicitGCInvokesConcurrent
  • 為什麼CMS GC下-XX:+ExplicitGCInvokesConcurrent這個引數加了之後會比真正的Full GC好?
  • 它如何做到暫停整個程式?
  • 堆外記憶體分配為什麼有時候要配合System.gc?
  • Netty回收堆外記憶體的策略又是如何?
小結

筆者也沒有想到一個簡單的引數能牽扯出這麼多內容,這裡做一個大概的總結:

  • Kafka官方曾經禁用過System.gc()
  • 後面有大神分析了指令碼和JDK的NIO原始碼,發現禁用System.gc()這不是有問題嘛,你Kafka大量使用Java的直接記憶體,直接記憶體靠一般的Gc是回收不掉的,只能靠Ful Gc順帶回收,JDK官方程式碼又是靠頻繁呼叫System.gc()強制騰出直接記憶體空間的,你System.gc()禁用了不是“找死”麼,於是趕緊解釋了一波 Bits#reserveMemory寫的“垃圾程式碼”來證實自己的觀點,然後建議啟用System.gc(),並且為了提高Full Gc效率使用-XX:+ExplicitGCInvokesConcurrent
  • 官方發現這個問題趕緊修復了一版並且提交了issue。

MaxInlineLevel

java 有一個引數 -XX:MaxInlineLevel(JDK14之前預設值為 9),這個值在JDK14之後預設值改為15。這個值的修改可以參考JDK官方的宣告 https://bugs.openjdk.org/browse/JDK-8234863

下面的圖Oracle官方對於JDK14版本之後修改MaxInlineLevel=15的Push。

調整MaxInlineLevel為15

下面長篇大論源自網上收集的資料和個人理解,其實簡單理解為現代硬體資源足以支援 -XX:MaxInlineLevel設定為15,更大的內聯深度可以讓JIT編譯出更多的原生程式碼從而提高Java程式碼的執行效率即可。

如果你的伺服器還是古舊的四五年前的機器,或者生產機器確實渣的可以,那麼還是建議把這個引數 -XX:MaxInlineLevel改回 9 比較妥當。

長篇大論的部分:

連結https://bugs.openjdk.org/browse/JDK-8234863的宣告指出,15這個值在scala上的效能測試是被認為最優結果。這個值在現代處理器速度以及效能最佳化較好的今天最為合適,預設值9這個數字顯得非常過時

Kafka作為激進壓榨機器效能的典範,也遵從JDK官方的改動預設所有版本的JDK統一使用15這個預設值。

這裡額外插一嘴,個人認為實際上這個值Oracle官方在JDK11就可以修改為15。

MaxInlineLevel本身的判斷邏輯似乎更引起廣大程式設計師的關注,StackFlow上有一篇關於這個引數的討論:https://stackoverflow.com/questions/32503669/why-does-the-jvm-have-a-maximum-inline-depth 比較有意思。

在評論中有網友指出在比較低的JDK8版本當中,MaxRecursiveInlineLevel對直接間接的遞迴呼叫都進行計數,編譯後的程式碼應該在執行時保持對整個內聯樹的跟蹤(以便能夠解壓和去最佳化)。緊接著是其他人的一些個人觀點,沒人接這個人話茬=-=,尷尬。

繼續翻閱,下面的評論有一位大佬解釋了為什麼會出現MaxInlineLevel這個引數,簡單易懂這裡就直接貼過來了:

One reason is also that the inlining itself in the HotSpot JVM is implemented with recursion. Every time inlining of a method is started a new context is created on the native stack. Allowing an unlimited depth would eventually make the JIT-compiler crash when it runs out of stack.

(舊版保守的限制方法內聯深度),其中一個原因是HotSpot JVM的內聯本身是用遞迴實現的。每次對一個方法進行內聯時,都會在本地堆疊中建立一個新的上下文。如果允許無限的深度,最終會使JIT-編譯器在堆疊耗盡時崩潰。

在過去硬體資源緊張的情況下,過度的方法內聯有可能會出現比較深的堆疊呼叫,十分消耗程式記憶體,但是現代記憶體動不動就是32,64,128G 的今天,加上處理器的核心數量上來了之後,擴大預設的方法內聯深度引數值確實非常有必要。

方法內聯是JVM比較底層的最佳化,可以透過周大神的《深入理解JVM虛擬機器第三版》瞭解。

如果不懂方法內聯直接無腦設定MaxInlineLevel=15即可,沒有為什麼,官方都已經在高版本JDK修改了預設值,JDK8忠實粉絲自然也可以這麼幹。

jdk14 hotspot 依賴的調整日誌:https://hg.openjdk.org/jdk8u/jdk8u/hotspot/

-Djava.awt.headless=true

這個引數比較奇怪,但是實際上在SpringBoot原始碼中也有同樣的寫法。

這算是一個不太被關注的最佳化引數,簡單理解是-Djava.awt.headless=true可以遮蔽掉一些不必要的外接裝置影響,告知程式當前沒有外接裝置,儘可能的讓程式底層自己模擬,比如列印從圖形顯示變為控制檯列印。

又是牽扯內容很多的一個點,具體解釋可以看這篇文章:[[【Java】The Java Headless Mode]],篇幅有限,這裡就不多解釋了。

KAFKA_GC_LOG_OPTS

見名知意,就是JVM的日誌引數配置,Kafka最終的日誌格式為:XXX-gc.log,日誌配置這一塊和大部分以JAVA為底層的開源元件大差不差,簡單的掃一眼差不多了。

# Log directory to use
# 獲取log_dir,如果沒配置就那 $base_dir 環境變數
if [ "x$LOG_DIR" = "x" ]; then
  # base_dir=$(dirname $0)/..
  LOG_DIR="$base_dir/logs"
fi


# GC options    
GC_FILE_SUFFIX='-gc.log'
GC_LOG_FILE_NAME=''
if [ "x$GC_LOG_ENABLED" = "xtrue" ]; then
  GC_LOG_FILE_NAME=$DAEMON_NAME$GC_FILE_SUFFIX

  # The first segment of the version number, which is '1' for releases before Java 9
  # it then becomes '9', '10', ...
  # Some examples of the first line of `java --version`:
  # 8 -> java version "1.8.0_152"
  # 9.0.4 -> java version "9.0.4"
  # 10 -> java version "10" 2018-03-20
  # 10.0.1 -> java version "10.0.1" 2018-04-17
  # We need to match to the end of the line to prevent sed from printing the characters that do not match
  JAVA_MAJOR_VERSION=$("$JAVA" -version 2>&1 | sed -E -n 's/.* version "([0-9]*).*$/\1/p')
  if [[ "$JAVA_MAJOR_VERSION" -ge "9" ]] ; then
    KAFKA_GC_LOG_OPTS="-Xlog:gc*:file=$LOG_DIR/$GC_LOG_FILE_NAME:time,tags:filecount=10,filesize=100M"
  else
    KAFKA_GC_LOG_OPTS="-Xloggc:$LOG_DIR/$GC_LOG_FILE_NAME -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M"
  fi
fi

JAVA_MAJOR_VERSION就是透過正則去除JDK的主版本號。

  • 如果主版本號大於或者等於JDK9,就使用JDK9新增的 -xlog:gc* 統一的日誌引數作為啟動引數,
  • 如果是JDK8之前的版本,就需要用一大堆舊版的日誌引數,學習和使用成本比較大:

    • GCLogFileSize=100M,限制GC日誌檔案大小為100M。
    • NumberOfGCLogFiles=10,允許存在的GC日誌檔案數量為10個。
    • UseGCLogFileRotation,讓GC日誌不斷迴圈,如果最後一個GC日誌寫滿,將會從第一個檔案重新開始寫入
    • -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps 是舊版本混亂的GC引數配置誕生的惡果,這些引數在JDK9之後被統統被-xlog:gc* 替代。

      • -verbose:gc -XX:+PrintGCDetails這兩個引數經常在低版本JDK一起出現,最大的區別是前者是穩定版本,後者則是被認為是不穩定的日誌啟動引數(強制和其他GC引數配合出現顯得不穩定)。
      • -XX:+PrintGCDateStamps:每行開頭顯示當前絕對的日期及時間,列印GC發生時的時間戳,搭配 -XX:+PrintGCDetails 使用,不可以獨立使用。
      • -XX:+PrintGCTimeStamps 自從JVM啟動以來的時間。

-XX:+PrintGCDateStamps-XX:+PrintGCTimeStamps可以直接看下面的例子對比:

-XX:+PrintGCDateStamps
日誌輸出示例:
2014-01-03T12:08:38.102-0100: [GC 66048K->53077K(251392K), 0,0959470 secs]
2014-01-03T12:08:38.239-0100: [GC 119125K->114661K(317440K), 0,1421720 secs]

-XX:+PrintGCTimeStamps
日誌輸出示例:
0,185: [GC 66048K->53077K(251392K), 0,0977580 secs]
0,323: [GC 119125K->114661K(317440K), 0,1448850 secs]
因為-XX:+PrintGCDetails被標記為manageable,所以可以透過如下三種方式修改:
1、com.sun.management.HotSpotDiagnosticMXBean API
2、JConsole
3、jinfo -flag

最後再把英文註釋部分簡單翻譯一下:

  1. 第一個引數如果是1開頭,代表是JDK9之後的版本。
  2. java --version產生的結果如下:

    • 8 -> java version "1.8.0_152"
    • 9.0.4 -> java version "9.0.4"
    • 10 -> java version "10" 2018-03-20
    • 10.0.1 -> java version "10.0.1" 2018-04-17
  3. 透過正規表示式匹配到行尾,以防止sed列印出不匹配的字元

KAFKA_JMX_OPTS

JMX全稱Java Management Extensions, 為Java應用提供管理擴充套件功能。在JDK 5的時候引入,Kafka設定啟動引數讓Kafka應用程式獲得JMX遠端呼叫的支援。

# JMX settings
if [ -z "$KAFKA_JMX_OPTS" ]; then
  KAFKA_JMX_OPTS="-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.authenticate=false  -Dcom.sun.management.jmxremote.ssl=false "
fi

KAFKA_JMX_OPTS對應的value的含義參考自:https://www.jianshu.com/p/414647c1179e,此處列舉一些有關JMX的相關引數:

引數名型別描述
-Dcom.sun.management.jmxremote布林是否支援遠端JMX訪問,預設true
-Dcom.sun.management.jmxremote.port數值監聽埠號,方便遠端訪問
-Dcom.sun.management.jmxremote.authenticate布林是否需要開啟使用者認證,預設開啟
-Dcom.sun.management.jmxremote.ssl布林是否對連線開啟SSL加密,預設開啟
-Dcom.sun.management.jmxremote.access.file路徑對訪問使用者的許可權授權的檔案的路徑,預設路徑JRE_HOME/lib/management/jmxremote.access
-Dcom.sun.management.jmxremote. password.file路徑設定訪問使用者的使用者名稱和密碼,預設路徑JRE_HOME/lib/management/ jmxremote.password

KAFKA_LOG4J_OPTS

log4j的日誌配置地址。

if [ "x$KAFKA_LOG4J_OPTS" = "x" ]; then  
    export KAFKA_LOG4J_OPTS="-Dlog4j.configuration=file:$base_dir/../config/log4j.properties"  
fi  

配置含義不需要記憶,在閱讀的時候查閱相關資料即可:https://www.jianshu.com/p/ccafda45bcea,這裡直接貼過來作為註釋部分供讀者參考。

# 根Log
# 預設日誌等級為INFO級別
# NFO、WARN、ERROR和FATAL級別的日誌資訊都會輸出
# 日誌最終輸出到kafkaAppender
log4j.rootLogger=INFO, stdout, kafkaAppender  
# 控制檯配置
log4j.appender.stdout=org.apache.log4j.ConsoleAppender  
# 佈局模式使用可以靈活模式
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout  
# 日誌列印格式
# [%d] 輸出日誌時間點的日期或時間,預設格式為ISO8601,也可以在其後指定格式,如:%d{yyyy/MM/dd HH:mm:ss,SSS}。
# %m::輸出程式碼中指定的具體日誌資訊。
# %p:輸出日誌資訊的優先順序,即DEBUG,INFO,WARN,ERROR,FATAL。
# %c:輸出日誌資訊所屬的類目,通常就是所在類的全名。
# %n:輸出一個回車換行符,Windows平臺為"\r\n",Unix平臺為"\n"。
log4j.appender.stdout.layout.ConversionPattern=[%d] %p %m (%c)%n  

# DailyRollingFileAppender Kafka預設服務端日誌
log4j.appender.kafkaAppender=org.apache.log4j.DailyRollingFileAppender  
log4j.appender.kafkaAppender.DatePattern='.'yyyy-MM-dd-HH  
# server.log 儲存位置
log4j.appender.kafkaAppender.File=${kafka.logs.dir}/server.log  
log4j.appender.kafkaAppender.layout=org.apache.log4j.PatternLayout  
log4j.appender.kafkaAppender.layout.ConversionPattern=[%d] %p %m (%c)%n  

# DailyRollingFileAppender 狀態機變更日誌
log4j.appender.stateChangeAppender=org.apache.log4j.DailyRollingFileAppender  
# 按照每小時產生一個日誌的方式
log4j.appender.stateChangeAppender.DatePattern='.'yyyy-MM-dd-HH   
log4j.appender.stateChangeAppender.File=${kafka.logs.dir}/state-change.log  
log4j.appender.stateChangeAppender.layout=org.apache.log4j.PatternLayout  
log4j.appender.stateChangeAppender.layout.ConversionPattern=[%d] %p %m (%c)%n  

# 請求日誌
log4j.appender.requestAppender=org.apache.log4j.DailyRollingFileAppender  
log4j.appender.requestAppender.DatePattern='.'yyyy-MM-dd-HH  
log4j.appender.requestAppender.File=${kafka.logs.dir}/kafka-request.log  
log4j.appender.requestAppender.layout=org.apache.log4j.PatternLayout  
log4j.appender.requestAppender.layout.ConversionPattern=[%d] %p %m (%c)%n  

# Log清理日誌
log4j.appender.cleanerAppender=org.apache.log4j.DailyRollingFileAppender  
log4j.appender.cleanerAppender.DatePattern='.'yyyy-MM-dd-HH  
log4j.appender.cleanerAppender.File=${kafka.logs.dir}/log-cleaner.log  
log4j.appender.cleanerAppender.layout=org.apache.log4j.PatternLayout  
log4j.appender.cleanerAppender.layout.ConversionPattern=[%d] %p %m (%c)%n  

# Controller 日誌
log4j.appender.controllerAppender=org.apache.log4j.DailyRollingFileAppender  
log4j.appender.controllerAppender.DatePattern='.'yyyy-MM-dd-HH  
log4j.appender.controllerAppender.File=${kafka.logs.dir}/controller.log  
log4j.appender.controllerAppender.layout=org.apache.log4j.PatternLayout  
log4j.appender.controllerAppender.layout.ConversionPattern=[%d] %p %m (%c)%n  

# 驗證日誌
log4j.appender.authorizerAppender=org.apache.log4j.DailyRollingFileAppender  
log4j.appender.authorizerAppender.DatePattern='.'yyyy-MM-dd-HH  
log4j.appender.authorizerAppender.File=${kafka.logs.dir}/kafka-authorizer.log  
log4j.appender.authorizerAppender.layout=org.apache.log4j.PatternLayout  
log4j.appender.authorizerAppender.layout.ConversionPattern=[%d] %p %m (%c)%n  
  
# Change the line below to adjust ZK client logging  
# 修改下面的日誌控制ZK的日誌輸出
log4j.logger.org.apache.zookeeper=INFO  
  
# Change the two lines below to adjust the general broker logging level (output to server.log and stdout)  
# 更改下面兩行以調整一般代理日誌記錄級別(輸出到 server.log 和 stdout)
log4j.logger.kafka=INFO  
log4j.logger.org.apache.kafka=INFO  
  
# Change to DEBUG or TRACE to enable request logging  
# 修改日誌級別為 DEBUG和TRACE獲取請求日誌
log4j.logger.kafka.request.logger=WARN, requestAppender  
log4j.additivity.kafka.request.logger=false  
  
# Uncomment the lines below and change log4j.logger.kafka.network.RequestChannel$ to TRACE for additional output  
# 取消註釋下面的行並將 log4j.logger.kafka.network.RequestChannel$ 更改為 TRACE 以獲得額外的輸出
# related to the handling of requests  
# 與請求的處理相關
#log4j.logger.kafka.network.Processor=TRACE, requestAppender  
#log4j.logger.kafka.server.KafkaApis=TRACE, requestAppender  
#log4j.additivity.kafka.server.KafkaApis=false  

log4j.logger.kafka.network.RequestChannel$=WARN, requestAppender  
log4j.additivity.kafka.network.RequestChannel$=false  
  
log4j.logger.kafka.controller=TRACE, controllerAppender  
log4j.additivity.kafka.controller=false  
  
log4j.logger.kafka.log.LogCleaner=INFO, cleanerAppender  
log4j.additivity.kafka.log.LogCleaner=false  
  
log4j.logger.state.change.logger=INFO, stateChangeAppender  
log4j.additivity.state.change.logger=false  
  
# Access denials are logged at INFO level, change to DEBUG to also log allowed accesses 
# 拒絕訪問記錄在 INFO 級別,更改為 DEBUG 以記錄允許的訪問
log4j.logger.kafka.authorizer.logger=INFO, authorizerAppender  
log4j.additivity.kafka.authorizer.logger=false

KAFKA_OPTS

KAFKA_OPTS 可以在這裡設定自己的想要的通用配置:

# Generic jvm settings you want to add
if [ -z "$KAFKA_OPTS" ]; then
  KAFKA_OPTS=""
fi

UPGRADE_KAFKA_STREAMS_TEST_VERSION

變數名稱翻譯過來是“升級kafka流的測試版本”,這裡大致的意思是取出版本號進行一些判斷之後設定到ClassPath當中。

說實話這部分內容看不太懂,但是不算是十分重要的東西,可以以後深入之後回來瞭解,這裡直接忘記這個設定即可。

if [ -z "$UPGRADE_KAFKA_STREAMS_TEST_VERSION" ]; then
  for file in "$base_dir"/streams/examples/build/libs/kafka-streams-examples*.jar;
  do
    if should_include_file "$file"; then
      CLASSPATH="$CLASSPATH":"$file"
    fi
  done
else
  VERSION_NO_DOTS=`echo $UPGRADE_KAFKA_STREAMS_TEST_VERSION | sed 's/\.//g'`
  SHORT_VERSION_NO_DOTS=${VERSION_NO_DOTS:0:((${#VERSION_NO_DOTS} - 1))} # remove last char, ie, bug-fix number
  for file in "$base_dir"/streams/upgrade-system-tests-$SHORT_VERSION_NO_DOTS/build/libs/kafka-streams-upgrade-system-tests*.jar;
  do
    if should_include_file "$file"; then
      CLASSPATH="$file":"$CLASSPATH"
    fi
  done
  if [ "$SHORT_VERSION_NO_DOTS" = "0100" ]; then
    CLASSPATH="/opt/kafka-$UPGRADE_KAFKA_STREAMS_TEST_VERSION/libs/zkclient-0.8.jar":"$CLASSPATH"
    CLASSPATH="/opt/kafka-$UPGRADE_KAFKA_STREAMS_TEST_VERSION/libs/zookeeper-3.4.6.jar":"$CLASSPATH"
  fi
  if [ "$SHORT_VERSION_NO_DOTS" = "0101" ]; then
    CLASSPATH="/opt/kafka-$UPGRADE_KAFKA_STREAMS_TEST_VERSION/libs/zkclient-0.9.jar":"$CLASSPATH"
    CLASSPATH="/opt/kafka-$UPGRADE_KAFKA_STREAMS_TEST_VERSION/libs/zookeeper-3.4.8.jar":"$CLASSPATH"
  fi
fi

CLASSPATH

執行 ./gradlew copyDependantLibs 來獲取本地目錄下的所有依賴性jar。

注意這裡劃分了很多個子模組,所以使用了for迴圈載入到CLASSPATH當中,這會導致最終產生的命令會非常長。

# run ./gradlew copyDependantLibs to get all dependant jars in a local dir
shopt -s nullglob
if [ -z "$UPGRADE_KAFKA_STREAMS_TEST_VERSION" ]; then
  for dir in "$base_dir"/core/build/dependant-libs-${SCALA_VERSION}*;
  do
    CLASSPATH="$CLASSPATH:$dir/*"
  done
fi

for file in "$base_dir"/examples/build/libs/kafka-examples*.jar;
do
  if should_include_file "$file"; then
    CLASSPATH="$CLASSPATH":"$file"
  fi
done

個人嘗試了一下注釋介紹的gradle copyDependantLibs命令,本地執行結果如下,這個命令會在對應的模組構建依賴jar包:

$ gradle copyDependantLibs

> Configure project :
Building project 'core' with Scala version 2.13.3
Building project 'streams-scala' with Scala version 2.13.3

Deprecated Gradle features were used in this build, making it incompatible with Gradle 7.0.
Use '--warning-mode all' to show the individual deprecation warnings.
See https://docs.gradle.org/6.6.1/userguide/command_line_interface.html#sec:command_line_warnings

BUILD SUCCESSFUL in 2s
55 actionable tasks: 3 executed, 52 up-to-date

個人是win11的電腦,透過wox查詢dependant-libs結果如下:

image-20230224133728509

對應的一堆依賴jar包

image-20230224133744539

CONSOLE_OUTPUT_FILE

日誌的列印輸出地址檔案地址設定,注意不是GC的日誌。

  case $COMMAND in
    -name)
      DAEMON_NAME=$2
      CONSOLE_OUTPUT_FILE=$LOG_DIR/$DAEMON_NAME.out
      shift 2
      ;;

這裡翻閱了一些有關shift的資料:

關於Shift的作用可以參考:https://ss64.com/bash/shift.html。Linux中透過help shift檢視使用手冊,但是會發現寫的比較潦草和抽象。
shift: shift [n]
    Shift positional parameters.
    
    Rename the positional parameters $N+1,$N+2 ... to $1,$2 ...  If N is
    not given, it is assumed to be 1.
    
    Exit Status:
    Returns success unless N is negative or greater than $#

比如下面的程式:

#! /bin/bash

echo $1 
echo $2

shift 1

echo $1
echo $2

# 輸出結果
[zxd@localhost ~]$ ./test.sh 1 2 3 5 6
1
2

2
3

shift 1 執行之後會彈出第一個引數,之後的執行引數會往前“推動”,$1變為$2的值,$2變為$3的值,以此類推。

& 後臺啟動和nohup掛起程式

末尾部分是設定ClaassPath,使用者自己自定義引數以及把標準輸入和輸出重定向到同一個位置,最後就是以後臺模式啟動並且最終透過nohup掛起整個程式。

-cp "$CLASSPATH" $KAFKA_OPTS "$@" > "$CONSOLE_OUTPUT_FILE" 2>&1 < /dev/null &

這段指令碼最前面把替代引數意義進行了替換。-cp是nohup命令的引數,接著是把輸出的結果全部重定向到標準輸出當中,這個地址對應CONSOLE_OUTPUT_FILE

下面理解最後部分的nohup和&2>&1/dev/null這幾個常見的服務端指令碼啟動引數的含義。

nohup和&

nohup:nohup指令會忽略所有結束通話(SIGHUP)訊號不結束通話的執行。注意nohup命令本身並沒有後臺執行的功能,需要配合&使用。它的實現原理是讓命令不間斷的執行實現掛機的效果。

& 是指在後臺執行,但當使用者退出(解除掛起)的時候,命令自動也跟著退出,nohup&這兩個指令通常會放到一起使用。

/dev/null

這個空間屬於Linux的一塊特殊空間,UNIX系統中,它被稱為空裝置。以下內容摘自維基百科:

/dev/null(或稱空裝置)在類Unix系統中是一個特殊的裝置檔案,它丟棄一切寫入其中的資料(但報告寫入操作成功),讀取它則會立即得到一個EOF[1]。

在程式設計師行話,尤其是Unix行話中,/dev/null被稱為位元桶或者黑洞

2>&1的問題

前面的第一個數字2通常對應下面幾種含義:

  • 0 – stdin (standard input) 標準輸入
  • 1 – stdout (standard output) 標準輸出
  • 2 – stderr (standard error) 標準錯誤輸出

\> 是重定向符號,而數字2的含義是標準錯誤輸出,&1指的就是標準輸出,三個符號組合到一起就是把標準錯誤輸出輸入重定向到標準輸出當中,這裡可以理解為“合流”。

注意命令2>&12>1是存在區別的,這裡&不能丟,後者的1代表輸出代表錯誤重定向到一個檔案1,不代表標準輸出,只有&1才代表標準輸出。

如果想要丟棄所有的標準錯誤輸出和標準輸出結果,下面是一個不錯的例子:

nohup python3 getfile.py > /dev/null 2>&1 &

如果想要寫入到指定的位置,下面是又一個不錯的例子:

nohup python3 getfile.py > test.log 2>&1 &

最後是實際一點的例子:

0 9 \* \* \* /usr/bin/python3 /opt/getFile.py > /opt/file.log 2>&1
上面的命令含義是放在crontab中的定時任務,每天9:00啟動這個python的指令碼,並把執行結果寫入日誌檔案file.log中

exec 執行

如果不是守護程式的執行,則是使用exec在當前的shell中進行正常模式啟動,此時整個shell會掛起執行kafka服務端。

  exec "$JAVA" $KAFKA_HEAP_OPTS $KAFKA_JVM_PERFORMANCE_OPTS $KAFKA_GC_LOG_OPTS $KAFKA_JMX_OPTS $KAFKA_LOG4J_OPTS -cp "$CLASSPATH" $KAFKA_OPTS "$@"

其他內容

Lauch modes 使用到的變數設定包含了啟動整個Kafka服務端的核心部分,下面再列覺其他的依賴配置以及“輔助”內容。

Scala 版本選擇

Kafka是使用Java和Scala混合編寫的,根據不同的Kafka版本需要不同版本的Scala版本支援,這裡官方做了一個版本選擇強制判斷選擇出最合適的Scala。

if [ -z "$SCALA_VERSION" ]; then
  SCALA_VERSION=2.13.3
  if [[ -f "$base_dir/gradle.properties" ]]; then
    SCALA_VERSION=`grep "^scalaVersion=" "$base_dir/gradle.properties" | cut -d= -f 2`
  fi
fi

gradle.properties

Kafka 專案是基於gradle構建的,gradle 個人平時基本沒啥接觸機會,這裡做一個大致配置瞭解。

group=org.apache.kafka  
# NOTE: When you change this version number, you should also make sure to update  
# the version numbers in  
#  - docs/js/templateData.js  
#  - tests/kafkatest/__init__.py  
#  - tests/kafkatest/version.py (variable DEV_VERSION)  
#  - kafka-merge-pr.py  
version=2.7.2  
scalaVersion=2.13.3  
task=build  
org.gradle.jvmargs=-Xmx2g -Xss4m -XX:+UseParallelGC

gradle這裡分配的是2g的堆記憶體,Xss4m每個執行緒的堆疊大小為4M,最後是使用ParallelGC垃圾收集器,也是JDK8的預設垃圾收集器。

DEBUG模式

如果在啟動引數裡面設定了KAFKA_DEBUG,就可以開啟DEBUG模式。

# Set Debug options if enabled
if [ "x$KAFKA_DEBUG" != "x" ]; then

    # Use default ports
    DEFAULT_JAVA_DEBUG_PORT="5005"

    if [ -z "$JAVA_DEBUG_PORT" ]; then
        JAVA_DEBUG_PORT="$DEFAULT_JAVA_DEBUG_PORT"
    fi

    # Use the defaults if JAVA_DEBUG_OPTS was not set
    DEFAULT_JAVA_DEBUG_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=${DEBUG_SUSPEND_FLAG:-n},address=$JAVA_DEBUG_PORT"
    if [ -z "$JAVA_DEBUG_OPTS" ]; then
        JAVA_DEBUG_OPTS="$DEFAULT_JAVA_DEBUG_OPTS"
    fi

    echo "Enabling Java debug options: $JAVA_DEBUG_OPTS"
    KAFKA_OPTS="$JAVA_DEBUG_OPTS $KAFKA_OPTS"
fi

我們需要了解的是 JAVA_DEBUG_OPTS 命令的含義。起初雖然不是很懂下面的引數含義,但是可以知道是JAVA除錯應用程式用的。

-agentlib:jdwp=transport=dt_socket,server=y,suspend=${DEBUG_SUSPEND_FLAG:-n},address=$JAVA_DEBUG_PORT

我們除錯程式更多是在IDE裡面,下面的內容來自網路資料整合參考和理解:

Debugging Java applications) 這篇文章大概介紹瞭如何在JVM啟動之後除錯JAVA程式,以及如何在使用JDK除錯應用程式。

若要除錯 Java 程式,可以使用 Java 偵錯程式 (JDB) 應用程式或其他偵錯程式,這些偵錯程式透過使用 SDK 為作業系統提供的 Java™ 平臺偵錯程式體系結構 (JPDA) 進行通訊。

在Linux系統當中進行JAVA程式除錯可以使用下面的命令。對於我們來說這些寫法照著寫就行,不需要過分追究具體的含義。

java -agentlib:jdwp=transport=dt_socket,server=y,address=_<port>_ <class>

除錯遠端伺服器執行的JAVA應用程式,在Window中和Linux中除錯方式如下:

  • On Windows systems:
jdb -connect com.sun.jdi.SocketAttach:hostname=<host>,port=<port>
  • On other systems:
jdb -attach <host>:<port>

此外Stack-Flow上還有一個寫的更棒的帖子,這篇帖子的引數和Kafka的指令碼部分基本一致了。

debugging - What are Java command line options to set to allow JVM to be remotely debugged? - Stack Overflow

Before Java 5.0, use -Xdebug and -Xrunjdwp arguments. These options will still work in later versions, but it will run in interpreted mode instead of JIT, which will be slower.

JDK5之前的版本這裡可以直接忽略。(知道了也沒啥用處)

From Java 5.0, it is better to use the -agentlib:jdwp single option:

JDK5之後使用下面的命令格式:

-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=1044

Options on -Xrunjdwp or agentlib:jdwp arguments are :

  • transport=dt_socket : means the way used to connect to JVM (socket is a good choice, it can be used to debug a distant computer)
  • address=8000 : TCP/IP port exposed, to connect from the debugger,
  • suspend=y : if 'y', tell the JVM to wait until debugger is attached to begin execution, otherwise (if 'n'), starts execution right away.
  • transport=dt_socket :表示用於連線JVM的方式(socket是一個不錯的選擇,它可以用來除錯遠端計算機)
  • address=8000 : TCP / IP埠公開,從偵錯程式連線。
  • suspend=y :如果為“y”,則告訴 JVM 等到連線偵錯程式後再開始執行,否則(如果為“n”),立即開始執行。

最後對比一下Kafka的引數,豁然開朗。

-agentlib:jdwp=transport=dt_socket,server=y,suspend=${DEBUG_SUSPEND_FLAG:-n},address=$JAVA_DEBUG_PORT

Which java to use

如註釋所言查詢java命令在哪。

if [ -z "$JAVA_HOME" ]; then
  JAVA="java"
else
  JAVA="$JAVA_HOME/bin/java"
fi

Memory options

記憶體配置選項如下:

if [ -z "$KAFKA_HEAP_OPTS" ]; then
  KAFKA_HEAP_OPTS="-Xmx256M"
fi
-Xmxn:指定記憶體分配池的最大大小(以位元組為單位)。此值的倍數必須大於 2MB,1024 的倍數。

這裡設定最大的HEAP大小為256M。

cc_pkg

同樣是jar包依賴的查詢和引入到ClassPath當中,這裡同樣不知道幹啥用的,簡單理解是獲取必要依賴項即可。

for cc_pkg in "api" "transforms" "runtime" "file" "mirror" "mirror-client" "json" "tools" "basic-auth-extension"
do
  for file in "$base_dir"/connect/${cc_pkg}/build/libs/connect-${cc_pkg}*.jar;
  do
    if should_include_file "$file"; then
      CLASSPATH="$CLASSPATH":"$file"
    fi
  done
  if [ -d "$base_dir/connect/${cc_pkg}/build/dependant-libs" ] ; then
    CLASSPATH="$CLASSPATH:$base_dir/connect/${cc_pkg}/build/dependant-libs/*"
  fi
done

Exclude jars not necessary for running commands.

排除命令不需要的jar包,比如test和javadoc等。

regex="(-(test|test-sources|src|scaladoc|javadoc)\.jar|jar.asc)$"
should_include_file() {
  if [ "$INCLUDE_TEST_JARS" = true ]; then
    return 0
  fi
  file=$1
  if [ -z "$(echo "$file" | egrep "$regex")" ] ; then
    return 0
  else
    return 1
  fi
}

INCLUDE_TEST_JARS

判斷是否開啟了包含測試的jar包。

if [ -z "$INCLUDE_TEST_JARS" ]; then
  INCLUDE_TEST_JARS=false
fi

寫在最後

不得不感嘆學無止境,知道的越多不知道的也就更多,一個指令碼里面居然有這麼多學問,本部分的核心毫無疑問是JVM的啟動引數,其他的引數或者配置以及奇怪的指令碼寫法看不懂 也沒啥關係,這裡僅僅對於一些個人關注的核心部分進行介紹,對於一些細枝末節不做過多的追究和鑽牛角尖,讀者感興趣可以對比參考資料做更多瞭解。

相關文章