blob: 13fa06bd8eff2ae026de8aef51c62f23ed3be01b [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
#include "util/jvm_launcher.h"
#include <bthread/bthread.h>
#include <fmt/format.h>
#include <glog/logging.h>
#include <csignal>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <iterator>
#include <mutex>
#include <sstream>
#include <thread>
#include "common/cast_set.h"
#include "common/config.h"
#include "common/metrics/doris_metrics.h"
#include "runtime/thread_context.h"
#include "util/defer_op.h"
#include "util/jni-util.h"
#include "util/thread.h"
namespace doris::Jni {
JavaVM* JvmLauncher::_vm = nullptr;
pthread_key_t JvmLauncher::_detach_key;
namespace {
std::string env_value(const char* name) {
const char* value = getenv(name);
return value == nullptr ? std::string() : std::string(value);
}
// Splits an option string on whitespace, the way libhdfs splits LIBHDFS_OPTS.
std::vector<std::string> split_options(const std::string& options) {
std::istringstream stream(options);
return {std::istream_iterator<std::string>(stream), std::istream_iterator<std::string>()};
}
std::string join_options(const std::vector<std::string>& options) {
std::string joined;
for (const auto& option : options) {
if (!joined.empty()) {
joined += " ";
}
joined += option;
}
return joined;
}
// Every jar below `base_path`, in no particular order.
void collect_jars(const std::string& base_path, std::string* class_path) {
std::error_code ec;
if (!std::filesystem::exists(base_path, ec)) {
return;
}
for (const auto& entry : std::filesystem::recursive_directory_iterator(base_path, ec)) {
if (entry.path().extension() == ".jar") {
if (!class_path->empty()) {
*class_path += ":";
}
*class_path += entry.path().string();
}
}
}
// Last resort for processes that were not started by start_be.sh, e.g. tests.
std::string scan_class_path() {
const std::string doris_home = env_value("DORIS_HOME");
if (doris_home.empty()) {
return "";
}
std::string class_path = doris_home + "/conf/";
collect_jars(doris_home + "/lib", &class_path);
collect_jars(doris_home + "/custom_lib", &class_path);
// Named separately because it is the one thing libhdfs needs that does NOT live under lib/:
// the third-party filesystem jars moved to plugins/jni_fs when the Java plugins started
// reading them too. Leaving it out would silently drop oss-hdfs:// and jfs:// for exactly the
// processes this branch serves. Read from the config rather than spelled out, because this
// runs from _build_options() - well after config is loaded - and an operator who points
// jni_plugin_fs_dir somewhere else must not end up with the Java plugins reading one
// directory and the native reader another.
collect_jars(config::jni_plugin_fs_dir, &class_path);
const std::string hadoop_conf_dir = env_value("HADOOP_CONF_DIR");
if (!hadoop_conf_dir.empty()) {
class_path += ":" + hadoop_conf_dir;
}
return class_path;
}
// The signals the BE keeps for itself and installs its own handlers for in init_signals():
// SIGINT and SIGTERM ask it to shut down - the handler raises the flag main() waits on, so
// the shutdown stays orderly: running queries are drained and the process leaves through
// _exit(), which is what keeps the global destructors from running underneath threads that
// are still working - and SIGQUIT is held so that it does nothing, because its default
// action is to terminate the process and dump core (see init_signals()).
//
// A starting JVM installs handlers of its own for all three, and its handler turns a
// shutdown signal into a Java Shutdown.exit() - an ::exit() call made from a JVM thread.
// That runs the global destructors while the BE is still serving, and BE singletons have
// been seen freed out from under a live compaction thread this way. The BE has always kept
// SIGINT and SIGTERM for itself; it used to do so by creating the JVM before
// init_signals(), which no longer holds now that the JVM is created on demand.
//
// Our own JVM is created with -Xrs, so it never installs them in the first place. This guard
// is for the JVM we do not configure: libhdfs creates one from LIBHDFS_OPTS when it finds no
// VM, and the options in there are the operator's. Saving the handlers and putting them back
// leaves a window in between, so the guard is wrapped as tightly as possible around the two
// calls that can create a VM - it used to span the whole bootstrap, seconds of hadoop
// ServiceLoader scanning included.
//
// Only these two. The JVM needs SIGSEGV, SIGBUS, SIGILL and SIGFPE for its implicit null
// checks and safepoint polls; taking those back would break it. SIGHUP is left to the JVM as
// well, which is what the eager-JVM order did too.
constexpr int BE_OWNED_SIGNALS[] = {SIGINT, SIGTERM, SIGQUIT};
constexpr size_t BE_OWNED_SIGNAL_COUNT = std::size(BE_OWNED_SIGNALS);
class BeOwnedSignalGuard {
public:
BeOwnedSignalGuard() {
for (size_t i = 0; i < BE_OWNED_SIGNAL_COUNT; ++i) {
_saved_valid[i] = sigaction(BE_OWNED_SIGNALS[i], nullptr, &_saved[i]) == 0;
if (!_saved_valid[i]) {
LOG(WARNING) << "failed to read the handler of signal " << BE_OWNED_SIGNALS[i]
<< " before starting the JVM, errno=" << errno
<< "; the JVM is about to take this signal over";
}
}
}
~BeOwnedSignalGuard() {
for (size_t i = 0; i < BE_OWNED_SIGNAL_COUNT; ++i) {
if (!_saved_valid[i]) {
continue;
}
if (sigaction(BE_OWNED_SIGNALS[i], &_saved[i], nullptr) != 0) {
LOG(WARNING) << "failed to restore the handler of signal " << BE_OWNED_SIGNALS[i]
<< " after starting the JVM, errno=" << errno
<< "; the JVM now owns this signal and shutting the BE down with "
"it will not be graceful";
}
}
}
BeOwnedSignalGuard(const BeOwnedSignalGuard&) = delete;
BeOwnedSignalGuard& operator=(const BeOwnedSignalGuard&) = delete;
private:
struct sigaction _saved[BE_OWNED_SIGNAL_COUNT] = {};
bool _saved_valid[BE_OWNED_SIGNAL_COUNT] = {};
};
} // namespace
std::string JvmLauncher::_class_path_option() {
// start_be.sh publishes one and the same class path under two names: CLASSPATH, which
// libhdfs used to build the JVM from, and DORIS_CLASSPATH, which is the same list
// already spelled as a JVM option. CLASSPATH wins because it is the one the JVM of
// every running BE was created from, and the only one that carries conf/.
std::string class_path = env_value("CLASSPATH");
if (class_path.empty()) {
static constexpr std::string_view kOptionPrefix = "-Djava.class.path=";
std::string doris_class_path = env_value("DORIS_CLASSPATH");
if (doris_class_path.starts_with(kOptionPrefix)) {
doris_class_path = doris_class_path.substr(kOptionPrefix.size());
}
class_path = doris_class_path;
}
if (class_path.empty()) {
class_path = scan_class_path();
}
return "-Djava.class.path=" + class_path;
}
std::vector<std::string> JvmLauncher::_build_options() {
// start_be.sh exports the very same options as JAVA_OPTS and as LIBHDFS_OPTS; reading
// both means a deployment that only sets one of them still gets its own settings.
std::string java_opts = env_value("JAVA_OPTS");
if (java_opts.empty()) {
java_opts = env_value("LIBHDFS_OPTS");
}
std::vector<std::string> options;
if (!java_opts.empty()) {
options = split_options(java_opts);
} else {
const std::string doris_home = env_value("DORIS_HOME");
options = {
"-Xmx1g",
fmt::format("-DlogPath={}/log/jni.log", doris_home),
"-Dsun.java.command=DorisBE",
"-XX:-CriticalJNINatives",
#ifdef __APPLE__
// On macOS, we should disable MaxFDLimit, otherwise the RLIMIT_NOFILE
// will be assigned the minimum of OPEN_MAX (10240) and rlim_cur (See
// src/hotspot/os/bsd/os_bsd.cpp) and it can not pass the check performed
// by storage engine. The newer JDK has fixed this issue.
"-XX:-MaxFDLimit",
#endif
};
}
options.push_back(_class_path_option());
options.push_back("-Djava.security.krb5.conf=" + config::kerberos_krb5_conf_path);
options.push_back(fmt::format("-Djdk.lang.processReaperUseDefaultStackSize={}",
config::jdk_process_reaper_use_default_stack_size));
// Where PluginRegistry looks for plugins, where it lets them read hadoop configuration files
// from, and where it finds the third-party filesystem jars every plugin may need. All three
// travel as system properties rather than through the startup script because the values are BE
// configs and this is the one place that turns BE config into JVM options; passing them from
// the script as well would be two sources for one path.
options.push_back("-Ddoris.jni.plugin.dir=" + config::jni_plugin_dir);
options.push_back("-Ddoris.jni.hadoop.conf.dir=" + config::jni_plugin_hadoop_conf_dir);
options.push_back("-Ddoris.jni.fs.dir=" + config::jni_plugin_fs_dir);
// The JVM must not install handlers for SIGINT/SIGTERM/SIGHUP: its handler turns a shutdown
// signal into Java's Shutdown.exit(), an ::exit() from a JVM thread that runs the global
// destructors while the BE is still serving. Restoring the handlers around bootstrap (see
// BeOwnedSignalGuard) only narrows that window - this closes it, for the whole life of the
// process. Appended last so that it also wins over a JAVA_OPTS that says otherwise.
//
// What this costs: no Java shutdown hooks on those signals, which is the point, and no JVM
// thread dump from SIGQUIT - the JVM does not install its handler for that one either. That
// is why the BE installs one of its own in init_signals(): SIGQUIT's default action is to
// terminate the process and dump core, so leaving it at SIG_DFL would turn the operator's
// habitual `kill -3 <be_pid>` from "print the thread dump" into "kill the BE". jcmd and
// jstack, which attach instead of signalling, are the way to get a Java thread dump now.
options.push_back("-Xrs");
return options;
}
// libhdfs runs this right after it creates the VM, and hadoop's FileSystem remembers the
// service-loaded implementations it finds in a static field, populated exactly once with
// whatever context class loader the first caller happens to carry. Doing it here keeps
// that first caller the BE itself rather than some scanner thread whose context class
// loader belongs to a plugin.
void JvmLauncher::_load_file_systems(JNIEnv* env) {
jclass file_system_cl = env->FindClass("org/apache/hadoop/fs/FileSystem");
if (file_system_cl == nullptr) {
env->ExceptionClear();
LOG(INFO) << "hadoop FileSystem is not on the class path, skip loading its file systems";
return;
}
Defer defer {[&]() { env->DeleteLocalRef(file_system_cl); }};
jmethodID load_file_systems = env->GetStaticMethodID(file_system_cl, "loadFileSystems", "()V");
if (load_file_systems == nullptr) {
env->ExceptionClear();
LOG(WARNING) << "hadoop FileSystem has no loadFileSystems method, skip loading its file "
"systems";
return;
}
env->CallStaticVoidMethod(file_system_cl, load_file_systems);
if (env->ExceptionCheck()) {
env->ExceptionDescribe();
env->ExceptionClear();
LOG(WARNING) << "failed to load the hadoop file systems, hdfs access may not work";
}
}
Status JvmLauncher::_create_jvm() {
JavaVM* created_vms[1] = {nullptr};
jsize num_vms = 0;
jint rv = JNI_GetCreatedJavaVMs(created_vms, 1, &num_vms);
if (rv != JNI_OK) {
return Status::JniError("Failed to look up the JVM of this process, code={}", rv);
}
if (num_vms > 0) {
// Somebody got there first - in practice libhdfs, whose entry points create a JVM of
// their own when they do not find one, out of CLASSPATH and LIBHDFS_OPTS. Those are the
// same two the options below are built from, so the VM is usable, but it is NOT
// identically configured: the six settings this launcher adds - -Xrs, the krb5 conf
// path, the three doris.jni.* directories and the process-reaper stack size - are missing
// from it. The branch is unreachable today, because every libhdfs entry point in the BE
// runs behind ensure_jvm(); it is here for the case where that stops being true.
_vm = created_vms[0];
LOG(INFO) << "Reuse the JVM that already exists in this process.";
return Status::OK();
}
const std::vector<std::string> options = _build_options();
std::vector<JavaVMOption> jvm_options(options.size());
for (size_t i = 0; i < options.size(); ++i) {
jvm_options[i] = {const_cast<char*>(options[i].c_str()), nullptr};
}
JavaVMInitArgs vm_args;
vm_args.version = JNI_VERSION_1_8;
vm_args.options = jvm_options.data();
vm_args.nOptions = cast_set<jint>(options.size());
// Options the JVM does not recognize at all are dropped rather than rejected, which is
// how libhdfs created the JVM of every deployed BE so far - it passed JNI_TRUE too, so
// this is not a change for any running deployment. It only covers unrecognized -X and
// _ options: one HotSpot recognizes but cannot parse the value of, a mistyped -Xmx8gb
// among them, still fails JNI_CreateJavaVM with JNI_EINVAL. That failure is cached for
// the life of the process (see ensure_jvm), which disables every Java feature and every
// HDFS access with it, so it is reported at ERROR below rather than as a warning.
vm_args.ignoreUnrecognized = JNI_TRUE;
JNIEnv* env = nullptr;
jint res = JNI_OK;
{
BeOwnedSignalGuard be_owned_signals;
res = JNI_CreateJavaVM(&_vm, reinterpret_cast<void**>(&env), &vm_args);
}
if (res != JNI_OK) {
_vm = nullptr;
// libhdfs may have created one in the window since the look-up above, in which
// case there is nothing wrong - we just lost the race for who configures it.
if (JNI_GetCreatedJavaVMs(created_vms, 1, &num_vms) == JNI_OK && num_vms > 0) {
_vm = created_vms[0];
LOG(INFO) << "Reuse the JVM another thread created while we were creating ours.";
return Status::OK();
}
Status failure = Status::JniError(
"Failed to create the JVM, code={}, options=[{}]. Every Java feature of this BE "
"and all HDFS access are disabled until it is restarted; a JVM option this "
"process cannot parse - a mistyped -Xmx among them - is the usual cause.",
res, join_options(options));
LOG(ERROR) << failure;
return failure;
}
LOG(INFO) << "Created the JVM with options: " << join_options(options);
_load_file_systems(env);
return Status::OK();
}
Status JvmLauncher::_bootstrap() {
RETURN_IF_ERROR(_create_jvm());
// Hand libhdfs its thread-local key before we create ours. Both of us detach a thread
// when it exits, and a thread that reads hdfs files and runs a JNI scanner ends up
// registered with both. libhdfs' destructor dereferences the JNIEnv it cached for the
// thread (jni_helper.c: `(*env)->GetJavaVM(env, &vm)`, and more JNI calls if the
// detach fails), which DetachCurrentThread has freed by then - so it has to run
// first. Destructors run in ascending key order, so creating its key first is what
// orders them. Dropping this call is safe only once libhdfs stops touching the env it
// cached.
{
// Guarded as well: getJNIEnv() is libhdfs' own entry point for creating a VM when it
// finds none. Ours exists by now, so this is belt and braces.
BeOwnedSignalGuard be_owned_signals;
if (getJNIEnv() == nullptr) {
LOG(WARNING) << "libhdfs failed to attach the bootstrap thread to the JVM; its thread "
"exit hook may now run after ours";
}
}
if (int rc = pthread_key_create(&_detach_key, &JvmLauncher::_detach_current_thread); rc != 0) {
return Status::JniError("Failed to register the JVM thread exit hook, errno={}", rc);
}
// The JNI base belongs to the JVM this just created, so it is resolved here rather than on
// whichever thread first asks for a JNIEnv: the native methods Java code links against and
// the cached classes both come out of doris-jni-spi.jar, and resolving them once, here, is
// what keeps every later Env::Get() on its fast path.
//
// Attached and cached by hand rather than through attach_current_thread(): that one calls
// ensure_jvm(), and we are inside its call_once. Priming the thread-local env is also what
// keeps Env::Get() below on its fast path, for the same reason.
JNIEnv* env = nullptr;
RETURN_IF_ERROR(_attach_current_thread(&env));
Env::set_tls_env(env);
Status base = Util::ensure_jni_base();
// The jvm_* metrics describe the JVM and nothing else - JvmStats::init() reaches only for
// java.lang.management classes - so they are published whenever a JVM exists, whoever brought
// it up and whether or not the plugin SPI resolved. Publishing them anywhere but here would
// mean a BE that has a JVM but reached it through libhdfs (an HDFS catalog served by the
// native reader, no JNI table format and no Java UDF anywhere) exports no jvm_* series at all
// and has its whole Java heap counted as untracked memory.
//
// Reached from inside this call_once, so it must not touch anything that leads back to
// ensure_jvm(). What keeps it off that path is ScopedVmEnv, which JvmStats::init() takes and
// holds across its whole body; the primed env above is not what makes this safe and neither
// is the position before reset_tls_env(), because the metrics daemon reruns the same code
// later on a thread that was never primed at all.
DorisMetrics::instance()->init_jvm_metrics();
if (!base.ok()) {
// The env stays valid, but nothing that reads it may run without the base - so leave
// this thread looking unattached, exactly as GetJNIEnvSlowPath() does on failure.
Env::reset_tls_env();
// Deliberately NOT this function's answer. A failure here means doris-jni-spi.jar is
// missing or unreadable, which disables every Java feature - but the JVM is up, and
// libhdfs needs nothing else from it. Returning the failure used to make ensure_jvm()
// fail as well, and since hdfs_file_system.cpp and hdfs_mgr.cpp both go through
// ensure_jvm(), a half-finished upgrade that had not yet laid down lib/jni/spi took every
// HDFS read down with it, once per query. Java entry points get this same cached status
// from Env::GetJNIEnvSlowPath(), which is the only door they come through.
LOG(ERROR) << "The JVM is up but the Java plugin SPI could not be resolved, so no Java "
"code can run in this process (HDFS through libhdfs is unaffected): "
<< base;
}
return Status::OK();
}
Status JvmLauncher::ensure_jvm() {
if (!config::enable_java_support) {
return Status::InternalError(
"Java support is disabled, you can change be config enable_java_support to true "
"and restart be.");
}
static std::once_flag jvm_once;
// The JVM is created at most once, so the outcome of that attempt is the answer for
// every caller that follows.
static Status jvm_status;
std::call_once(jvm_once, []() { jvm_status = _bootstrap_on_pthread(); });
return jvm_status;
}
// https://brpc.apache.org/docs/server/basics/
// JNI code checks the stack layout and cannot run on a bthread, and the bootstrap below is all
// JNI: JNI_CreateJavaVM, FindClass, libhdfs' getJNIEnv. The switch belongs here rather than at
// the call sites because a caller is whichever query first touches Java - HdfsMgr makes the same
// switch for the connection it goes on to open, but by then the JVM has already been created,
// and every future caller would have to know to repeat it.
//
// Joining does park the calling bthread's worker for as long as the JVM takes to come up. It
// happens once per process, and the alternative - a butex wait - would put brpc internals into
// the launcher. (Before the JVM became lazy there was nothing to park for: it was created in
// main(), long before any bthread existed.)
Status JvmLauncher::_bootstrap_on_pthread() {
if (bthread_self() == 0) { // already a pthread
return _bootstrap_guarded();
}
Status status;
std::thread bootstrap([&status]() {
SCOPED_INIT_THREAD_CONTEXT();
Thread::set_self_name("jvm_bootstrap");
status = _bootstrap_guarded();
});
bootstrap.join();
return status;
}
// The bootstrap walks the plugin and lib directories: collect_jars() passes an error_code to
// recursive_directory_iterator's constructor but not to its operator++, which throws when a
// directory becomes unreadable mid-iteration. On the std::thread branch above an exception leaving
// the body is std::terminate, which is what this started out guarding - but the throwing path is
// reachable only when neither CLASSPATH nor DORIS_CLASSPATH is set, i.e. for a process NOT started
// by start_be.sh, and such a process is a plain pthread and takes the other branch. So the guard
// belongs here, around the one function, where it covers both. Same reason as the plugin warmup
// thread in doris_main.cpp.
Status JvmLauncher::_bootstrap_guarded() {
try {
return _bootstrap();
} catch (const std::exception& e) {
return Status::JniError("Failed to create the JVM: {}", e.what());
} catch (...) {
return Status::JniError("Failed to create the JVM: unknown exception");
}
}
Status JvmLauncher::attach_current_thread(JNIEnv** env) {
RETURN_IF_ERROR(ensure_jvm());
return _attach_current_thread(env);
}
JNIEnv* JvmLauncher::_tls_env() {
return Env::tls_env();
}
void JvmLauncher::_set_tls_env(JNIEnv* env) {
Env::set_tls_env(env);
}
Status JvmLauncher::ScopedVmEnv::attach(JNIEnv** env) {
DCHECK(!_primed) << "ScopedVmEnv::attach() called twice on one guard";
if (_vm == nullptr) {
return Status::JniError(
"This process has no JVM, so there is no JNIEnv to look at. ScopedVmEnv "
"deliberately does not create one.");
}
JNIEnv* attached = nullptr;
RETURN_IF_ERROR(_attach_current_thread(&attached));
// Read before the write, and put back rather than cleared in the destructor: this runs on the
// bootstrap thread too, which primed the cache itself before calling in and still needs it
// afterwards to finish resolving the JNI base.
_previous = _tls_env();
_set_tls_env(attached);
_primed = true;
*env = attached;
return Status::OK();
}
JvmLauncher::ScopedVmEnv::~ScopedVmEnv() {
if (_primed) {
_set_tls_env(_previous);
}
}
Status JvmLauncher::_attach_current_thread(JNIEnv** env) {
JNIEnv* thread_env = nullptr;
jint rc = _vm->GetEnv(reinterpret_cast<void**>(&thread_env), JNI_VERSION_1_8);
if (rc == JNI_EDETACHED) {
rc = _vm->AttachCurrentThread(reinterpret_cast<void**>(&thread_env), nullptr);
}
if (rc != JNI_OK || thread_env == nullptr) {
return Status::JniError("Failed to attach the current thread to the JVM, code={}", rc);
}
// Arm the thread exit hook, also for a thread that arrived already attached - creating
// the JVM attaches whoever did it, and libhdfs attaches whoever calls it first. Any
// non-null value makes the hook run; the env is the honest one to store even though
// the hook must not dereference it.
if (pthread_getspecific(_detach_key) == nullptr) {
if (int err = pthread_setspecific(_detach_key, thread_env); err != 0) {
LOG(WARNING) << "Failed to arm the JVM thread exit hook, this thread stays "
"attached to the JVM after it exits, errno="
<< err;
}
}
*env = thread_env;
return Status::OK();
}
void JvmLauncher::_detach_current_thread(void* /*attached_env*/) {
// Deliberately not touching the JNIEnv handed out for this thread: detaching frees it,
// and this hook is exactly the place where a freed env turns into a crash inside the
// JVM - see the ordering note in _bootstrap().
Env::reset_tls_env();
if (_vm != nullptr) {
// JNI_EDETACHED here just means libhdfs' hook already detached this thread.
static_cast<void>(_vm->DetachCurrentThread());
}
}
} // namespace doris::Jni