【TopDesk】3.1.3. FindClass & ClassDefNotFoundException & Classloader & Tomcat
来源:互联网 发布:从新网代理商转出域名 编辑:程序博客网 时间:2024/05/12 08:57
山穷水尽疑无路,柳暗花明又一村。
0x06 家庭联产承包制
各位,对不起,我食言了。
食了什么言呢?上期最后我说耳机检测这个功能已经完成了,接下来不会在底层线展开了——就是这一句。
事实上这个功能还远远没有开发完成,而我也成功地踩到了JNI开发中的第二个坑。对此,容我先卖个关子,从前因后果讲起。
上次结束时我们已经成功实现了检测耳机插拔,并且用一个事件监听器的设计来允许其他程序调用监听耳机插拔的事件。于是,自然而然地,我把Java部分的设计稍微修改了一下,打包成JAR文件,导入到了我的后端项目中:
(注:本篇并不设计后端具体内容,无需在意细节)
之所以headphone.dll在jar里面,是因为System.loadLibrary("headphone")
只能在java.library.path
的位置查找dll文件,而这个全局变量在服务器启动后并不能修改,而为了配置一个dll文件就去修改启动的catalina.bat
又不符合WebAPP的热插拔目的,于是就想了这样一个折衷的方法:
System.loadLibrary()
如果传递一个库的名称为参数的话就回到java.library.path
中查找,然而也可以传递一个完整的路径,直接加载这个路径所制定的库。因此我们可以在运行时动态地把dll文件放到一个临时的位置,也就是从Jar包里面复制出来,然后传递这个位置的路径让JVM去加载dll。实现如下:
package io.github.std4453.topdesk.headphone;import java.io.IOException;import java.io.InputStream;import java.nio.file.Files;import java.nio.file.Path;import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;public class LibraryLoader { /** * Load embedded library file. * * @param libPath * Library name plus extension suffix. */ public static void loadLibrary(String libPath) { try { int point = libPath.lastIndexOf('.'), slash = libPath.lastIndexOf('/'); String prefix = libPath.substring(slash + 1, point), // without '/' suffix = libPath.substring(point); // with '.' InputStream in = LibraryLoader.class.getResource(libPath).openStream(); Path tmpFile = Files.createTempFile(prefix, suffix); Files.copy(in, tmpFile, REPLACE_EXISTING); System.load(tmpFile.toString()); tmpFile.toFile().deleteOnExit(); } catch (IOException e) { throw new RuntimeException(e); } }}
而HeadphoneDetector
则是为了做到底层与顶层解耦,从原先的HeadphonePeer
中分离出来的一个类:
package io.github.std4453.topdesk.headphone;import java.util.ArrayList;import java.util.List;/** * Singleton class for headphone insertion / removal event listening. */public class HeadphoneDetector { static { // Throws an exception if library not found. LibraryLoader.loadLibrary("/headphone.dll"); } /** * The only instance of {@link HeadphoneDetector}. */ public static final HeadphoneDetector instance = new HeadphoneDetector(); /** * An instance of {@link HeadphonePeer} is kept to avoid it being garbage-collected. */ private HeadphonePeer peer; private boolean inserted; private List<HeadphoneEventListener> listeners; private HeadphoneDetector() { this.inserted = false; // default: removed this.listeners = new ArrayList<>(); this.peer = new HeadphonePeer(this); // start listening String msg = this.peer.nStartListening(); if (msg != null) { // exception handling this.peer.nStopListening(); throw new RuntimeException(msg); } // add shutdown hook to prevent memory leak Runtime.getRuntime().addShutdownHook(new Thread(this.peer::nStopListening)); } synchronized void notifyExternal() { if (!this.inserted) { this.inserted = true; this.listeners.forEach(HeadphoneEventListener::onHeadphoneInserted); } } synchronized void notifyInternal() { if (this.inserted) { this.inserted = false; this.listeners.forEach(HeadphoneEventListener::onHeadphoneRemoved); } } /** * Thread-safe to avoid {@link java.util.ConcurrentModificationException}. */ public synchronized void addListener(HeadphoneEventListener listener) { this.listeners.add(listener); }}
package io.github.std4453.topdesk.headphone;class HeadphonePeer { private HeadphoneDetector detector; HeadphonePeer(HeadphoneDetector detector) { this.detector = detector; } /** * Called by native code when a <code>OnPropertyValueChanged</code> event arrives. * * @param name * The friendly name of the endpoint device whose property value is changed in * this event. Guaranteed non-null. */ private void onEvent(String name) { if (name.startsWith("External")) this.detector.notifyExternal(); else if (name.startsWith("Internal")) this.detector.notifyInternal(); } /** * Native method to start listening for <code>OnPropertyValueChanged</code> events. * * @return Error message, <code>null</code> when succeeded. */ native String nStartListening(); /** * Native method to stop listening for the events and release the resources. This * method <B>MUST</B> be called exactly once before VM shutdown or memory leak might * occur. */ native void nStopListening();}
至此,我们只要在项目中导入这个headphone.jar
,然后
使用HeadphoneDetector.instance.addListener(...)
就可以监听耳机插拔事件了。一切看起来都是那么的美好。
如果各位还记得我总起篇所说的整体架构的话应该还记得,显示端和服务端中间的数据通道是通过WebSocket来实现的,因此我们在有WebSocket客户端接入的时候加入一个耳机插拔的监听器,当事件到来时想客户端发送消息。
客户端代码从简(毕竟这里不是在讲具体的插件实现):
const messages = document.querySelector("#messages");const wsURL = "ws://localhost/topdesk/testws";let ws = new WebSocket(wsURL);ws.onmessage = evt => { let div = document.createElement("div"); div.classList.add("message"); div.innerHTML = evt.data; messages.appendChild(div);};
服务端:
package io.github.std4453.topdesk;import io.github.std4453.topdesk.headphone.HeadphoneDetector;import io.github.std4453.topdesk.headphone.HeadphoneEventListener;import javax.websocket.*;import javax.websocket.server.ServerEndpoint;import java.io.IOException;@ServerEndpoint(value = "/testws")public class TestWS { @OnOpen public void onOpen(Session session) { RemoteEndpoint.Basic remote = session.getBasicRemote(); HeadphoneDetector.instance.addListener(new HeadphoneEventListener() { @Override public void onHeadphoneInserted() { try { if (session.isOpen()) remote.sendText("Headphone inserted!"); } catch (IOException e) { e.printStackTrace(); } } @Override public void onHeadphoneRemoved() { try { if (session.isOpen()) remote.sendText("Headphone removed!"); } catch (IOException e) { e.printStackTrace(); } } }); }}
运行一下(Disconnected from server这里之前插了一下耳机):
Boom Shakalaka!
这里Disconnected from server实际上是因为JNI部分引发了Access Violation,不过为了确定具体导致崩溃的原因,我们在c++代码里面加入了一些log输出。(代码没什么改变就不贴了,可以对照着上篇的代码看log)再次运行如下:
这下局势一下清楚多了:JNI的FindClass会导致NoClassDefFoundError,因此返回的dpclazz应该是0,再拿着这个为0的dpclazz去getMethodID导致了Access Violation,Boom shakalaka!
这种情况下,相信正常人都会认为是JNI代码写得有问题,处于控制变量的目的,我写了一个最简的测试代码来测试使用headphone.jar是否有问题:
import io.github.std4453.topdesk.headphone.HeadphoneDetector;import io.github.std4453.topdesk.headphone.HeadphoneEventListener;import java.io.BufferedReader;import java.io.IOException;import java.io.InputStreamReader;public class HeadphoneTest { public static void main(String[] args) throws IOException { HeadphoneDetector.instance.addListener(new HeadphoneEventListener() { @Override public void onHeadphoneInserted() { System.out.println("INS"); } @Override public void onHeadphoneRemoved() { System.out.println("REM"); } }); new BufferedReader(new InputStreamReader(System.in)).readLine(); }}
导入headphone.jar作为外部依赖:
编译运行:
完全正常!!!
从控制变量的角度出发,我们用完全符合标准的写法写好了JNI部分,然后导出成了一个在测试项目中运行毫无问题的JAR,仅仅是把他放到了Tomcat服务器里面就导致了崩溃,这到底是为什么呢?
0x07 不管黑猫还是白猫
这个问题说来就话长了。
因为运行输出的错误信息基本都是在ClassLoader里面,自然就会想到是因为ClassLoader找不到需要找的类。这话当然是没错,但是这并不能解决之前提出的这个问题:为什么在一个普通项目中运行正常,到了Tomcat环境中就会找不到类呢?
首先是在网上找到了这么一个线索:
- 对于各个webapp中的class和lib,需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况;而对于许多应用,需要有共享的lib以便不浪费资源,举个例子,如果webapp1和webapp2都用到了log4j,可以将log4j提到tomcat/lib中,表示所有应用共享此类库,试想如果log4j很大,并且20个应用都分别加载,那实在是没有必要的。
- 第二个原因则是与jvm一样的安全性问题。使用单独的classloader去装载tomcat自身的类库,以免其他恶意或无意的破坏;
- 第三个原因是热部署的问题。相信大家一定为tomcat修改文件不用重启就自动重新装载类库而惊叹吧。
这就提醒了我:会不会正是因为WebAPP跟Tomcat的其他部分的相互隔离的特性,才导致了我自己写的类无法从JNI中正确加载呢?
不过首先,我们必须先搞清楚JNI中的FindClass到底是用的什么方法来加载类。由于Tomcat启动时候也是默认用的系统中装好的JVM,也就是HopSpot VM,我们这里就从最接近的OpenJDK出发,从源代码看起:
jdk8u60 / hotspot / scr / share / vm / prims / jni.cpp:424
JNI_ENTRY(jclass, jni_FindClass(JNIEnv *env, const char *name)) // 省略 // 这一步的目的是找到使用的ClassLoader并且存在loader中 //%note jni_3 Handle loader; Handle protection_domain; // Find calling class 找到当前线程上调用者的类 instanceKlassHandle k (THREAD, thread->security_get_caller_class(0)); // 如果k存在,则使用k的ClassLoader进行加载 if (k.not_null()) { loader = Handle(THREAD, k->class_loader()); // Special handling to make sure JNI_OnLoad and JNI_OnUnload are executed // in the correct class context. if (loader.is_null() && k->name() == vmSymbols::java_lang_ClassLoader_NativeLibrary()) { JavaValue result(T_OBJECT); JavaCalls::call_static(&result, k, vmSymbols::getFromClass_name(), vmSymbols::void_class_signature(), thread); if (HAS_PENDING_EXCEPTION) { Handle ex(thread, thread->pending_exception()); CLEAR_PENDING_EXCEPTION; THROW_HANDLE_0(ex); } oop mirror = (oop) result.get_jobject(); loader = Handle(THREAD, InstanceKlass::cast(java_lang_Class::as_Klass(mirror))->class_loader()); protection_domain = Handle(THREAD, InstanceKlass::cast(java_lang_Class::as_Klass(mirror))->protection_domain()); } } else { // 否则使用ClassLoader.getSystemClassLoader // We call ClassLoader.getSystemClassLoader to obtain the system class loader. loader = Handle(THREAD, SystemDictionary::java_system_loader()); } // 这里是通过前面找到的loader和类名来加载对应的类 TempNewSymbol sym = SymbolTable::new_symbol(name, CHECK_NULL); result = find_class_from_class_loader(env, sym, true, loader, protection_domain, true, thread); // 省略 return result;JNI_END
代码先寻找当前线程中的调用者(或者更精确地说,是当前线程上调用栈中最后一帧的方法对应的类),如果这个类存在,就使用这个类的ClassLoader,不然就使用ClassLoader.getSystemClassLoader()返回的ClassLoader进行加载所需要的类。那么当前线程的调用者是谁呢?
jdk8u60 / hotspot / scr / vm / runtime / thread.cpp:4219
Klass* JavaThread::security_get_caller_class(int depth) { vframeStream vfst(this); vfst.security_get_caller_frame(depth); if (!vfst.at_end()) { return vfst.method()->method_holder(); } return NULL;}
意思大概已经解释过了,那么vframeStream又是怎么来的呢?
jdk8u60 / hotspot / src/ share / vm / runtime / thread.hpp:820
vframeArray* _vframe_array_head; // Holds the heap of the active vframeArrays vframeArray* _vframe_array_last; // Holds last vFrameArray we popped
显然vframeStream是从一个vframe的链表中拷贝得来的,而在我们的代码中有:
JNIEnv *env; vm -> AttachCurrentThread((void **)&env, NULL); jclass dpclazz = env -> FindClass("io/github/std4453/topdesk/headphone/HeadphonePeer");
AttachCurrentThread的实现在:
jdk8u60 / hotspot / src / share / vm / prims / jni.cpp:5333
static jint attach_current_thread(JavaVM *vm, void **penv, void *_args, bool daemon) { JavaVMAttachArgs *args = (JavaVMAttachArgs *) _args; // 如果当前线程已经有JNIEnv则直接返回 Thread* t = ThreadLocalStorage::get_thread_slow(); if (t != NULL) { // If the thread has been attached this operation is a no-op *(JNIEnv**)penv = ((JavaThread*) t)->jni_environment(); return JNI_OK; } // 创建新的JavaThread // Create a thread and mark it as attaching so it will be skipped by the // ThreadsListEnumerator - see CR 6404306 JavaThread* thread = new JavaThread(true); // Set correct safepoint info. The thread is going to call into Java when // initializing the Java level thread object. Hence, the correct state must // be set in order for the Safepoint code to deal with it correctly. thread->set_thread_state(_thread_in_vm); // Must do this before initialize_thread_local_storage thread->record_stack_base_and_size(); thread->initialize_thread_local_storage(); if (!os::create_attached_thread(thread)) { delete thread; return JNI_ERR; } // 省略 return JNI_OK;}
再看JavaThread的实现:
void JavaThread::initialize() { // Initialize fields // Set the claimed par_id to UINT_MAX (ie not claiming any par_ids) set_claimed_par_id(UINT_MAX); set_saved_exception_pc(NULL); set_threadObj(NULL); _anchor.clear(); set_entry_point(NULL); set_jni_functions(jni_functions()); set_callee_target(NULL); set_vm_result(NULL); set_vm_result_2(NULL); // 这里设置了默认的空vframe array set_vframe_array_head(NULL); set_vframe_array_last(NULL); set_deferred_locals(NULL); set_deopt_mark(NULL); set_deopt_nmethod(NULL); clear_must_deopt_id(); set_monitor_chunks(NULL); set_next(NULL); set_thread_state(_thread_new); _terminated = _not_terminated; _privileged_stack_top = NULL; _array_for_gc = NULL; _suspend_equivalent = false; _in_deopt_handler = 0; _doing_unsafe_access = false; _stack_guard_state = stack_guard_unused; (void)const_cast<oop&>(_exception_oop = NULL); _exception_pc = 0; _exception_handler_pc = 0; _is_method_handle_return = 0; _jvmti_thread_state= NULL; _should_post_on_exceptions_flag = JNI_FALSE; _jvmti_get_loaded_classes_closure = NULL; _interp_only_mode = 0; _special_runtime_exit_condition = _no_async_condition; _pending_async_exception = NULL; _thread_stat = NULL; _thread_stat = new ThreadStatistics(); _blocked_on_compilation = false; _jni_active_critical = 0; _pending_jni_exception_check_fn = NULL; _do_not_unlock_if_synchronized = false; _cached_monitor_info = NULL; _parker = Parker::Allocate(this) ; // 省略}
到此为止,我们之前的研究就都联系起来了:
因为调用CMMNotificationClient
的线程是Windows自己创建的线程,因此不可能有JNIEnv
的缓存,因此在AttachCurrentThread()
中就会创建新的JavaThread
,然后JavaThread
的vframe array就是空的,因此当我们调用FindClass()
时,程序在security_get_caller_class()
中找不到caller frame就会返回NULL,因此FindClass()
就会选择使用ClassLoader.getSystemClassLoader()
所得到的ClassLoader
来加载我们想要的类,结果找不到类抛出NoClassDefFoundError
,我们继续强行调用GetMethodID
,内存Access Violation,程序就崩溃了。
为了最后做一个验证,在TestWS里面这么写:
@OnOpenpublic void onOpen(Session session) { RemoteEndpoint.Basic remote = session.getBasicRemote(); try { ClassLoader.getSystemClassLoader().loadClass( "io.github.std4453.topdesk.headphone.HeadphonePeer"); } catch (Exception e) { e.printStackTrace(); } HeadphoneDetector.instance.addListener(new HeadphoneEventListener() {...});}
运行一下:
果然如此。
然而想到我们在HeadphoneDetector.instance.addListener()
这句时会初始化instance,同时调用new HeadphonePeer(this)
(各位忘了的话可以回顾一下前面的代码),就要问,既然这个时候可以正常装载类,为什么在FindClass()
中就失败了呢?更进一步说,如果在一个普通的项目中使用的话,FindClass()
不是照样会调用ClassLoader.getSystemClassLoader()
来装载类吗?为什么就没有问题呢?
0x08 抓到老鼠就是好猫
还记得FindClass()
中的另一个分支吗?如果security_get_caller_class()
找到了调用的类的话,就使用这个类的ClassLoader
,既然HeadphoneDetector
的构造函数中可以成功加载HeadphonePeer
的类,我们不妨用它的ClassLoader
一试:
@OnOpenpublic void onOpen(Session session) { RemoteEndpoint.Basic remote = session.getBasicRemote(); try { HeadphoneDetector.class.getClassLoader().loadClass( "io.github.std4453.topdesk.headphone.HeadphonePeer"); System.out.println("Successfully loaded!"); } catch (Exception e) { e.printStackTrace(); } // 省略}
结果:
这两者的区别何在呢?
@OnOpenpublic void onOpen(Session session) { RemoteEndpoint.Basic remote = session.getBasicRemote(); try { System.out.println(HeadphoneDetector.class.getClassLoader() .getClass().getName()); System.out.println(ClassLoader.getSystemClassLoader() .getClass().getName()); } catch (Exception e) { e.printStackTrace(); } // 省略}
结果:
我们发现HeadphoneDetector的ClassLoader是一个Tomcat提供的特殊的ClassLoader。这是因为在一般的程序中,headphone.jar是在CLASSPATH中被传递给JVM的,ClassLoader.getSystemClassLoader()
会通过本身的ClassLoader从中去寻找并加载类。然而在Tomcat中,为了做到热部署,服务器会用一个自己的ClassLoader去装载一个Webapp所需要的类(WEB-INF/classes下)和库(WEB-INF/lib下)。而SystemClassLoader显然不可能知道都有哪些Webapp存在,也不会去加载Webapp所提供的类/库,因此就会抛出NoClassDefFoundError
,程序崩溃。
知道了原因,再来说解决方案。由于我们最好能做到兼容Tomcat下的运行和正常的包含jar模式,也不愿意大量修改已有的代码,于是可以有这样一种投机取巧的方法:
在HeadphoneDetector.<init>
中会调用到HeadphonePeer.nStartListener
,这个时候的线程上面的调用者自然就是HeadphoneDetector
,也是security_get_caller_class()
将返回的结果,因此如果在这个时候调用FindClass()
来查找类是可以成功的。
又因为jmethodID
的特殊性:
这三者都是java类别的属性,本质上都是指标(Pointer).透过这些指标就能快速调用java类别的函数,或存取对象的属性值。在该类别被载入时,这些指标值都是有效的,一直到该类别被卸载为止。其中jmethodID和jfieldID指标可以存在C语言的全局变量中,既能跨函数共享,又能跨进程共享。
(来源:【Tech-Android-Jni】Jni的Jclass JmethodID JfrieldID的差异)
于是就有了这样的思路:在nStartListener()
中调用FindClass()
获得jclass
,用GetMethodId()
获得jmethodID
,然后把它缓存起来在onEvent()
直接用,不用经过FindClass()
的过程,也就自然没有ClassLoader
的麻烦了。
这种思路本质上是使用HeadphoneDetector
的ClassLoader
,因为它能够正确加载HeadphoneDetector
,那也一定能够正确加载同一个jar中的HeadphonePeer
。代码如下:
(与前篇之前相同的内容省略掉了,调试信息没什么特别大的意义也删了)
#define SAFE_RELEASE(punk) \ if ((punk) != NULL) \ { (punk)->Release(); (punk) = NULL; } #include <stdlib.h>#include <stdio.h>#include <windows.h>#include <setupapi.h> #include <initguid.h>#include <mmdeviceapi.h> #include <Functiondiscoverykeys_devpkey.h>#include "io_github_std4453_topdesk_headphone_HeadphonePeer.h"JavaVM *vm;jobject g_obj;jmethodID onEventMethod = 0;jstring w2js(JNIEnv *env, LPCWSTR src) {...}void onEvent(LPCWSTR str) { JNIEnv *env; vm -> AttachCurrentThread((void **)&env, NULL); jstring jstr = w2js(env, str); // 直接利用缓存好的方法句柄,不经过FindClass() env -> CallObjectMethod(g_obj, onEventMethod, jstr); env -> DeleteLocalRef(jstr); vm -> DetachCurrentThread();}class CMMNotificationClient: public IMMNotificationClient {...};CMMNotificationClient *client;JNIEXPORT jstring JNICALL Java_io_github_std4453_topdesk_headphone_HeadphonePeer_nStartListening(JNIEnv * env, jobject obj) { env -> GetJavaVM(&vm); g_obj = env -> NewGlobalRef(obj); if (g_obj == NULL) return w2js(env, L"Cannot create global reference to obj!"); // 利用这个线程上的ClassLoader来查找类 jclass dpclazz = env -> FindClass("io/github/std4453/topdesk/headphone/HeadphonePeer"); // 查找并且缓存onEvent()的方法句柄 onEventMethod = env -> GetMethodID(dpclazz, "onEvent", "(Ljava/lang/String;)V"); env -> DeleteLocalRef(dpclazz); client = new CMMNotificationClient(); LPCWSTR msg = client -> startListener(); if (msg == NULL) return NULL; else return w2js(env, msg);}JNIEXPORT void JNICALL Java_io_github_std4453_topdesk_headphone_HeadphonePeer_nStopListening(JNIEnv * env , jobject obj) { delete client;}
运行一下:
(这是通过WebSocket传递到浏览器客户端上的显示,见前面的前端代码)
大功告成!
0x09 全面改革对外开放
终于,又到了絮絮叨叨的感慨时间了。
某种意义上来说,编程就是这样一个 发现问题 -> 提出假设 -> 设计实验 -> 验证假设 -> 实现方案 -> 解决问题 的过程,这在本篇博文中也有明显的体现。
先是发现了程序在Tomcat下崩溃的问题,然后在深入研究的过程提出了ClassLoader不兼容的假设,设计了利用不同的ClassLoader分别加载的实验验证了假设,最后实现了这个缓存jmethodID的方案,解决了问题。
它代表的是一种研究与探索的方法,在日后编程生涯中,碰到的问题会变,解决方法也会变;有的问题很难,可能需要积年累月的尝试才能找到其根源,有的则很简单,可能搞明白之后就是一两行代码的事情;有的是擅长熟悉的领域,也有的是完全不熟的领域。但是——只要遵循这一方法不变,就终究会有大功告成的那一刻。
有人说,程序员最有用的是学习能力,学习新知识并把它化作自己可以运用的工具的能力。
我倒是觉得,学习能力是研究探索的能力的一部分:在研究与探索的过程中,总会碰到之前没有涉及到的知识,而学习能力正在这里得到运用——学习并且理解这些新知识是利用它来解决问题的第一步。
因此,对程序员,乃至对于绝大多数职业来说,最重要的都应该是这一探究的能力。学会它,你将受用终身。
(以莫名其妙的鸡汤结束一篇技术博文还真的是……)
- 【TopDesk】3.1.3. FindClass & ClassDefNotFoundException & Classloader & Tomcat
- classloader之getresource,findClass深度分析
- Tomcat ClassLoader
- tomcat classloader
- Tomcat ClassLoader
- Tomcat classloader
- ClassLoader与Tomcat的ClassLoader
- 【TopDesk】3.1.1. 利用IMMNotificationClient实现耳机插拔检测
- Tomcat 7 FindClass org/apache/catalina/startup/Bootstrap failed
- Java-Classloader-loadeClass(String,boolean)、findClass(String)类加载源码解析
- Tomcat研究之ClassLoader
- ClassLoader in Tomcat[转贴]
- Tomcat研究之ClassLoader
- Tomcat的classloader
- Tomcat ClassLoader研究
- Tomcat研究之ClassLoader
- Tomcat ClassLoader机制介绍
- Tomcat学习之ClassLoader
- C# 函数与委托
- Java代理机制及动态代理和CGLIB代理详解
- 在U3D中用正则表达式实现FBX文件的自动分割
- java复习之数据类型转换
- 华为OJ——合唱队(序列的最长递增、递减序列)
- 【TopDesk】3.1.3. FindClass & ClassDefNotFoundException & Classloader & Tomcat
- 学生实时错误“366”、“91”
- Springboot集成ecache缓存配置
- leetcode
- 算法题目-赶去公司
- Ubuntu16.04.2中安装minidwep
- 几种左边固定右边自适应的左右布局方式
- CN200 1.8下片冲孔卡上模维修事例
- 一元二次方程求根公式推导过程