使用己有的C/C++库

JNI的一个使用方式就是编写一些本地方法来使用己有的本地库。本章介绍了一种生成一个包含一系列本地函数的类库的经典的方式。
本章首先用一对一映射这种(one-to-one mapping)最直接的方式来写封装类.接下来,我们会介绍一种叫做共享stubs(shared stubs)的技术来简化编写封装类的任务。然后,在本章的最后,我们会讨论怎么样使用peer classes来封装本地数据结构。
本章介绍的方式都是通过本地方法直接使用一个本地库,这样的话,应用程序调用本地方法时会依赖于本地库。这样应用程序只能运行在支持这个本地库的操作系统上面。一个更好的办法是声明一些与操作系统无关的本地方法,让这些方法来调用本地库。这样,当我们移植程序时,只需要修改这些实现中间层本地方法的本地函数就可以了,而不必动应用程序和这些中间层本地方法。
9.1 一对一映射(one-to-one mapping)
我们从一个简单的例子开始。假设我们想写一个封装类,它向标准C库提供atol函数:long atol(const char *str);
这个函数解析一个字符串并返回十进制数字。首先,我们像下面这样写:
public class C {
public static native int atol(String str);
...
}
为了演示如何使用C++进行JNI编程,我们用C++来实现本地方法:
JNIEXPORT jint JNICALL
Java_C_atol(JNIEnv *env, jclass cls, jstring str)
{
const char cstr = env->GetStringUTFChars(str, 0);
if (cstr == NULL) {
return 0; /
out of memory */
}
int result = atol(cstr);
env->ReleaseStringUTFChars(str, cstr);
return result;
}

9.2 Shred Stubs

一对一映射要求你为每一个你想封装的本地函数写一个stub函数,那么,当你需要为大量本地函数写封装类时,你的工作会很烦琐。本节中,我们介绍shared stubs的思想来简化工作量。
Shared stubs负责把调用者的请求分发到相应的本地函数,并负责把调用者提供的参数类型转化成本地函数需要的类型。
我们先看一下shared stubs怎么样简化C.atol方法的实现,然后还会介绍一个使用了shared stub思想的类CFunction。
public class C {
private static CFunction c_atol =
new CFunction("msvcrt.dll", // native library name
"atol", // C function name
"C"); // calling convention
public static int atol(String str) {
return c_atol.callInt(new Object[] {str});
}
...
}
C.atol不再是一个本地方法,而是使用CFunction类来定义。这个类内部实现了一个shared stub。静态变量C.c_atol存储了一个CFunction对象,这个对象对应了msvcrt.dll库中的C函数atol。一旦c_atol这个字段初始化,对C.atol的调用只需要调用c_atol.callInt这个shared stub。
一个CFunction类代表一个指向C函数的指针。



CFunction的类层次结构图如下:
public class CFunction extends CPointer {
public CFunction(String lib, // native library name
String fname, // C function name
String conv) { // calling convention
...
}
public native int callInt(Object[] args);
...
}
callInt方法接收一个java.lang.Object对象的数组作为参数,它检查数组中每一个元素的具体类型,并把它们转化成相应的C类型(比如,把String转化成char*)。然后把它们传递给相应的C函数,最后返回一个int型的结果。CFunction类还可以定义许多类似的方法,如callFloat、callDouble等来处理其它返回类型的C函数。
CPointor的定义如下:
public abstract class CPointer {
public native void copyIn(
int bOff, // offset from a C pointer
int[] buf, // source data
int off, // offset into source
int len); // number of elements to be copied
public native void copyOut(...);
...
}
CPointer是一个抽象类,它支持对任意C指针的访问。例如copyIn这个方法,它会把一个int数组里面的元素复制到C指针指向的位置中去。但是这种操作方式可以访问地址空间里面任意的内存位置,一定要小心地使用。像CPointer.copyIn这样的本地方法可对直接对C指针进行操作,是不安全的。CMalloc是CPointer的一个子类,它指向内存中由malloc在heap上分配的一块儿内存。
public class CMalloc extends CPointer {
public CMalloc(int size) throws OutOfMemoryError { ... }
public native void free();
...
}
CMalloc的构造函数根据给定的大小,在C的heap上创建一块儿内存。CMalloc.free方法用来释放这个内存块儿。我们可以用CFunction和CMalloc重新实现Win32.CreateFile:
public class Win32 {
private static CFunction c_CreateFile =
new CFunction ("kernel32.dll", // native library name
"CreateFileA", // native function
"JNI"); // calling convention

 public static int CreateFile(
     String fileName,          // file name
     int desiredAccess,        // access (read-write) mode 
     int shareMode,            // share mode 
     int[] secAttrs,           // security attributes 
     int creationDistribution, // how to create 
     int flagsAndAttributes,   // file attributes 
     int templateFile)         // file with attr. to copy
 {
     CMalloc cSecAttrs = null;
     if (secAttrs != null) {
         cSecAttrs = new CMalloc(secAttrs.length * 4);
         cSecAttrs.copyIn(0, secAttrs, 0, secAttrs.length);
     }
     try {
         return c_CreateFile.callInt(new Object[] {
                        fileName,
                        new Integer(desiredAccess),
                        new Integer(shareMode),
                        cSecAttrs,
                        new Integer(creationDistribution),
                        new Integer(flagsAndAttributes),
                        new Integer(templateFile)});
     } finally {
         if (secAttrs != null) {
             cSecAttrs.free();
         }
     }
 }
 ...

}
我们在一个静态变量当中缓存CFunction对象,Win32的CreateFile 这个API从kernel32.dll中通过调用方法CreateFileA来访问,另外一个方法CreateFileW需要传入一个Unicode字符串参数作为文件名。CFunction负责做标准的Win32调用转换(stdcall)。
上面的代码中,首先在C的heap上面分配一个足够大的内存块儿来存储安全属性,然后把所有的参数打包成一个数组并通过CFunction这个函数调用处理器来调用底层的C函数CreateFileA。最后释放掉存储安全属性的C内存块儿。

9.3 一对一映射(one-to-one mapping)和Shared Stubs的对比

这是两种把本地库封装成包装类的方式,各有自己的优点。
Shared Stubs的主要优点是程序员不必在本地代码中写一大堆的stub函数。一旦像CFunction这样的shared stub被创建以后,程序员可能就不用写代码了。
但是,使用shared stubs时一定要非常小心,因为这相当于程序员在JAVA语言中写C代码,已经违反了JAVA中的类型安全机制。一旦使用的过程中出现错误,就有可能引起内存破坏甚至程序崩溃。
一对一映射的优点是高效,因为它不需要太多附加的数据类型转换。而这一点正是shared stubs的缺点,例如CFunction.callInt必须为每一个int创建一个Integer对象。

9.4 如何实现Shared Stubs

到现在为止,我们一直是把CFunction、CPointer、CMalloc这三个类当作黑匣子的。本节中,我们就来详细描述一下它们是如何使用JNI实现的。

9.4.1 CPointer的实现

抽象类CPointer包含了一个64位的字段peer,它里面存放的是一个C指针:
public abstract class CPointer {
protected long peer;
public native void copyIn(int bOff, int[] buf,
int off,int len);
public native void copyOut(...);
...
}
对于像copyIn这样的本地方法的C++实现是比较简明的:
JNIEXPORT void JNICALL
Java_CPointer_copyIn__I_3III(JNIEnv *env, jobject self,
jint boff, jintArray arr, jint off, jint len)
{
long peer = env->GetLongField(self, FID_CPointer_peer);
env->GetIntArrayRegion(arr, off, len, (jint *)peer + boff);
}
在这里,我们假设FID_CPointer_peer是CPointer.peer的字段ID,是被提前计算出来。

9.4.2 CMalloc

CMalloc这个类中添加了两个本地方法用来分配和释放C内存块儿:
public class CMalloc extends CPointer {
private static native long malloc(int size);
public CMalloc(int size) throws OutOfMemoryError {
peer = malloc(size);
if (peer == 0) {
throw new OutOfMemoryError();
}
}
public native void free();
...
}
这个类的构造方法调用了本地方法CMalloc.malloc,如果CMalloc.malloc分配失败的话,会抛出一个OutOfMemoryError。我们可以像下面这样实现CMalloc.malloc和CMalloc.free两个方法:
JNIEXPORT jlong JNICALL
Java_CMalloc_malloc(JNIEnv *env, jclass cls, jint size)
{
return (jlong)malloc(size);
}

JNIEXPORT void JNICALL
Java_CMalloc_free(JNIEnv *env, jobject self)
{
long peer = env->GetLongField(self, FID_CPointer_peer);
free((void *)peer);
}

9.4.3 CFunction

这个类的实现要求操作系统支持动态链接,下面的代码是针对Win32/Intel X86平台的。一旦你理解了CFunction这个类背后的设计思想,你可以把它扩展到其它平台。
public class CFunction extends CPointer {
private static final int CONV_C = 0;
private static final int CONV_JNI = 1;
private int conv;
private native long find(String lib, String fname);

 public CFunction(String lib,     // native library name
                  String fname,   // C function name
                  String conv) {  // calling convention
     if (conv.equals("C")) {
         conv = CONV_C;
     } else if (conv.equals("JNI")) {
         conv = CONV_JNI;
     } else {
         throw new IllegalArgumentException(
                     "bad calling convention");
     }
     peer = find(lib, fname);
 }
 public native int callInt(Object[] args);
 ...

}
类中使用了一个conv字段来保存C函数的调用转换类型。
JNIEXPORT jlong JNICALL
Java_CFunction_find(JNIEnv *env, jobject self, jstring lib,
jstring fun)
{
void *handle;
void *func;
char *libname;
char *funname;

 if ((libname = JNU_GetStringNativeChars(env, lib))) {
     if ((funname = JNU_GetStringNativeChars(env, fun))) {
         if ((handle = LoadLibrary(libname))) {
             if (!(func = GetProcAddress(handle, funname))) {
                 JNU_ThrowByName(env, 
                     "java/lang/UnsatisfiedLinkError",
                     funname);
             }
         } else {
             JNU_ThrowByName(env, 
                     "java/lang/UnsatisfiedLinkError",
                     libname);
         }
         free(funname);
     }
     free(libname);
 }
 return (jlong)func;

}
CFunction.find把库名和函数名转化成本地C字符串,然后调用Win32下的APILoadLibrary和GetProcAddress来定义本地库中的函数。
方法callInt的实现如下:
JNIEXPORT jint JNICALL
Java_CFunction_callInt(JNIEnv *env, jobject self,
jobjectArray arr)
{

define MAX_NARGS 32

 jint ires;
 int nargs, nwords;
 jboolean is_string[MAX_NARGS];
 word_t args[MAX_NARGS];

 nargs = env->GetArrayLength(arr);
 if (nargs > MAX_NARGS) {
     JNU_ThrowByName(env, 
                 "java/lang/IllegalArgumentException",
                 "too many arguments");
     return 0;
 }

 // convert arguments
 for (nwords = 0; nwords < nargs; nwords++) {
     is_string[nwords] = JNI_FALSE;
     jobject arg = env->GetObjectArrayElement(arr, nwords);

     if (arg == NULL) {
         args[nwords].p = NULL;
     } else if (env->IsInstanceOf(arg, Class_Integer)) {
         args[nwords].i =
             env->GetIntField(arg, FID_Integer_value);
     } else if (env->IsInstanceOf(arg, Class_Float)) {
         args[nwords].f =
             env->GetFloatField(arg, FID_Float_value);
     } else if (env->IsInstanceOf(arg, Class_CPointer)) {
         args[nwords].p = (void *)
             env->GetLongField(arg, FID_CPointer_peer);
     } else if (env->IsInstanceOf(arg, Class_String)) {
         char * cstr =
             JNU_GetStringNativeChars(env, (jstring)arg);
         if ((args[nwords].p = cstr) == NULL) {
             goto cleanup; // error thrown
         }
         is_string[nwords] = JNI_TRUE;
     } else {
         JNU_ThrowByName(env,
             "java/lang/IllegalArgumentException",
             "unrecognized argument type");
         goto cleanup;
     }
     env->DeleteLocalRef(arg);
 }
 void *func = 
     (void *)env->GetLongField(self, FID_CPointer_peer);
 int conv = env->GetIntField(self, FID_CFunction_conv);

 // now transfer control to func.
 ires = asm_dispatch(func, nwords, args, conv);

cleanup:
// free all the native strings we have created
for (int i = 0; i < nwords; i++) {
if (is_string[i]) {
free(args[i].p);
}
}
return ires;
}
上面的代码中我们假设已经有了一些全局变量来缓存一些类引用和字段ID。例如,全局变量FID_CPointer_peer缓存了CPointer.peer的字段ID,而全局变量Class_String是对java.lang.String类对象的全局引用。类型word_t定义如下:
typedef union {
jint i;
jfloat f;
void *p;
} word_t;
函数Java_CFunction_callInt遍历参数数组并检查每一个元素的类型。
1、 如果元素是null,向C函数传递一个NULL指针。
2、 如果参数是java.lang.Integer类的实例,取出其中的int值并传递给C函数。
3、 如果元素是java.lang.Float类的实例,取出其中的float值传递给C函数。
4、 如果元素是一个CPointer类的实例,取出其中的peer指针并传递给C函数。
5、 如果参数是一个java.lang.String的实例,则把字符串转换成本地C字符串,然后传递给C函数。
6、 否则的话,抛出IllegalArgumentException。
在Java_CFunction_callInt函数之前,我们会在参数转换时检查可能会发生的错误,然后释放掉为C字符串临时分配的内存。
下面的代码需要把参数从临时缓冲区args中传递到C函数中,这个过程需要直接操作C的栈(stack),因此需要用到汇编,代码和对代码的解释不再翻译,懂得不多,翻译出来也是莫名其妙,不能保证正确性。
9.5 Peer
无论哪种封装方式,都会遇到一个问题,就是数据结构的传递。我们先看一下CPointer这个类的定义。
public abstract class CPointer {
protected long peer;
public native void copyIn(int bOff, int[] buf,
int off, int len);
public native void copyOut(...);
...
}
这个类中包含了一个指向本地数据结构的64位的peer字段。CPointer的子类用这个指针来操作C里面的数据结构:


CPointer、CMalloc这些类被称作peer classes。你可以用这些类封装各种各样的本地数据结构,如:
1、 文件描述符(file descriptors)。
2、 Socket描述符(socket descriptors)。
3、 窗口或者其它UI元素。
9.5.1 JAVA平台下的Peer Classes
JDK中,java.io、java.net和java.awt等包的内部实现就是利用了peer classes。例如,一个java.io.FileDescriptor类的实例,其实就包含了一个私有的字段fd,而fd这个字段就是指向一个本地文件描述符。
// Implementation of the java.io.FileDescriptor class
public final class FileDescriptor {
private int fd;
...
}
假如现在你想做一个JAVA平台的文件API不支持的操作,你可能就会通过本地方法中的JNI来找到一个java.io.FileDescriptor中的fd字段,然后试图去操作这个字段所代表的文件。这样会存在一些问题:
1、 首先,这种方式严重依赖于java.io.FileDescriptor的实现,如果有一天这个类的内部发生了变动,本地方法就要修改。
2、 你直接操作fd字段可能会破坏java.io.FileDescriptor内部的完整性。比如内部实现中,fd字段可能会和其它某个数据相关联。
解决这些问题最根本的方案就是定义你自己的peer classes来封装本地数据结构。在上面的情况中,你可以定义自己的peer class来包含file descriptor,并在这个peer class上面定义一些自己的操作。并且,你也可以很容易地定义一个自己的peer class来实现一个标准的JAVA API中的接口。
9.5.2 释放本地数据结构
Peer classes被定义在JAVA中,因此它们的实例对象会被自动回收,因此,你要保证在这些对象被回收的时候,它们所指向的C语言数据结构的内存块也要被释放。
前面提到过,CMalloc类包含一个用来手动释放被malloc分配的C内存的free方法:
public class CMalloc extends CPointer {
public native void free();
...
}
所以,有些人爱这么干:
public class CMalloc extends CPointer {
public native synchronized void free();
protected void finalize() {
free();
}
...
}
JVM在回收CMalloc的对象实例之前,会调用对象的finalize方法。这样的话,即使你忘记调用free,finalize方法也会帮你释放掉malloc分配的内存。
可是,为了防止本地方法被重复调用,你不仅要在free方法前面加上synchronized关键字,还需要对CMalloc.free这个本地方法的实现做一些修改:
JNIEXPORT void JNICALL
Java_CMalloc_free(JNIEnv env, jobject self)
{
long peer = env->GetLongField(self, FID_CPointer_peer);
if (peer == 0) {
return; /
not an error, freed previously */
}
free((void )peer);
peer = 0;
env->SetLongField(self, FID_CPointer_peer, peer);
}
请注意,要设置peer的值的话,需要用两句来完成:
peer = 0;
env->SetLongField(self, FID_CPointer_peer, peer);
而不是一句:
env->SetLongField(self, FID_CPointer_peer, 0);
因为C++编译器会把0当作32位int值来处理。
另外,定义finalize方法是一个很好的保障措施,但决不能把它作为释放本地C语言数据结构的主要方式:
1、 是本地数据结构可能会消耗比它们的peer对象实例更多的资源,但JVM看在眼里的是,这个对象只有一个long型的字段,这样JVM可能就会以为它占用很少的资源而不会及时回收掉。
2、 定义了finalize方法的类,在对象的创建和回收时可能会比没有定义finalize方法的类在效率上要差些。
其实,你完全不必用finalize方法就可以手动保证一个本地C语言数据结构被释放。但这样的话,你就必须确保在所有的执行路径上面都要执行释放代码,否则可能会造成内存泄漏。比如下面这种情况就是需要提起注意的:
CMalloc cptr = new CMalloc(10);
try {
... // use cptr
} finally {
cptr.free();
}
9.5.3 peer对象背后的东西
前面我们介绍了一个peer class通常会包含一个指向本地数据结构的私有字段。其实,有些情况下,在本地数据结构中包含一个指向peer class的引用也是很有用的。比如,当本地代码需要回调peer class中的实例方法的时候。
假设KeyInput是一个UI控件:
class KeyInput {
private long peer;
private native long create();
private native void destroy(long peer);
public KeyInput() {
peer = create();
}
public destroy() {
destroy(peer);
}
private void keyPressed(int key) {
... /
process the key event */
}
}
还有一个本地数据结构key_input:
// C++ structure, native counterpart of KeyInput
struct key_input {
jobject back_ptr; // back pointer to peer instance
int key_pressed(int key); // called by the operating system
};
它们的关系如下:

整个流程是这样的,JAVA当中生成一个KeyInput对象用来处理按键。KeyInput对象生成的时候,会在本地内存中创建一个key_input结构,这个结构中包含一个方法key_pressed供操作系统在发生事件时调用。
当用户按某个键时,操作系统产生一个事件,并调用key_pressed(int key);,在这个方法里面,本地代码会调用KeyInput的keyPressed方法,并把键值传入。
KeyInput的两个本地方法实现如下:
JNIEXPORT jlong JNICALL
Java_KeyInput_create(JNIEnv *env, jobject self)
{
key_input *cpp_obj = new key_input();
cpp_obj->back_ptr = env->NewGlobalRef(self);
return (jlong)cpp_obj;
}

JNIEXPORT void JNICALL
Java_KeyInput_destroy(JNIEnv *env, jobject self, jlong peer)
{
key_input cpp_obj = (key_input)peer;
env->DeleteGlobalRef(cpp_obj->back_ptr);
delete cpp_obj;
return;
}
本地方法create生成一个C++结构key_input,并初始化back_ptr字段。其中back_ptr是一个全局引用,指向KeyInput这个peer class对象的实例。本地方法destroy删除指向KeyInput对象的引用和KeyInput指向的本地数据结构。KeyInput构造方法调用本地方法create来建立KeyInput这个对象实例和它的副本key_input这个本地数据结构之间的链接。


Key_input::key_pressed(int key)方法的实现如下:
// returns 0 on success, -1 on failure
int key_input::key_pressed(int key)
{
jboolean has_exception;
JNIEnv *env = JNU_GetEnv();
JNU_CallMethodByName(env,
&has_exception,
java_peer,
"keyPressed",
"()V",
key);
if (has_exception) {
env->ExceptionClear();
return -1;
} else {
return 0;
}
}
本节结束之间,我们还有最后一个话题需要讨论。假设,你为KeyInput类添加了一个finalize方法来避免内存泄漏。
class KeyInput {
...
public synchronized destroy() {
if (peer != 0) {
destroy(peer);
peer = 0;
}
}
protect void finalize() {
destroy();
}
}
考虑到多线程的情况,destroy方法被加上了synchronized关键字。但是,上面的代码不会像你期望的那样执行的,因为JVM永远不会回收KeyInput这个对象,除非你手动调用destory方法。因为,KeyInput的构造方法创建了一个到KeyInput对象的JNI全局引用,这个全局引用会阻止GC回收KeyInput的。解决办法就是使用弱引用来替代全局引用:
JNIEXPORT jlong JNICALL
Java_KeyInput_create(JNIEnv *env, jobject self)
{
key_input *cpp_obj = new key_input();
cpp_obj->back_ptr = env->NewWeakGlobalRef(self);
return (jlong)cpp_obj;
}

JNIEXPORT void JNICALL
Java_KeyInput_destroy(JNIEnv *env, jobject self, jlong peer)
{
key_input cpp_obj = (key_input)peer;
env->DeleteWeakGlobalRef(cpp_obj->back_ptr);
delete cpp_obj;
return;
}

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,723评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,003评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,512评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,825评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,874评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,841评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,812评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,582评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,033评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,309评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,450评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,158评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,789评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,409评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,609评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,440评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,357评论 2 352

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,649评论 18 139
  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,621评论 18 399
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,394评论 0 17
  • _ 声明: 对原文格式以及内容做了细微的修改和美化, 主要为了方便阅读和理解 _ 一. 基础 Java Nativ...
    元亨利贞o阅读 5,912评论 0 34
  • 深圳,晴。异木棉现在正是花期,每天在去公司的路上、办公室外面的窗户、一楼大门口,都能看见它。这是来这里的第三个冬天...
    Echo可可阅读 202评论 0 1