假设我们想要用Java读取一个二进制文件,有好几种方式,本文会选取其中比较典型的三种方式进行详细分析

0. 准备工作

安装openjdk-1.8.0.141(普通的jdk中涉及IO的很多代码是闭源的,点进去是编译之后的字节码,没法看)

openjdk-1.8的c源码

1. FileInputStream.read

最朴素的方法就是先申请一段byte数组作为缓冲区,然后调用FileInputStream.read方法把文件里的数据灌到缓冲区中,代码如下所示

        FileInputStream reader = new FileInputStream(fileName);
byte[] buf = new byte[1024 * 64];//在heap memory中开辟一块缓冲区
while (reader.read(buf) != -1) {//调用FileInputStream.read()方法遍历文件
}

FileInputStream.read方法会直接调用到FileInputStream.readBytes()方法,这是一个native方法,具体实现位于src/share/native/java/io/FileInputStream.c

JNIEXPORT jint JNICALL
Java_java_io_FileInputStream_readBytes(JNIEnv *env, jobject this,
jbyteArray bytes, jint off, jint len) {
return readBytes(env, this, bytes, off, len, fis_fd);
}

它调用了src/share/native/java/io/io_util.c的readBytes方法:

jint
readBytes(JNIEnv *env, jobject this, jbyteArray bytes,
jint off, jint len, jfieldID fid)
{
jint nread;
char stackBuf[BUF_SIZE];
char *buf = NULL;
FD fd; if (IS_NULL(bytes)) {
JNU_ThrowNullPointerException(env, NULL);
return -;
} if (outOfBounds(env, off, len, bytes)) {
JNU_ThrowByName(env, "java/lang/IndexOutOfBoundsException", NULL);
return -;
} if (len == ) {
return ;
} else if (len > BUF_SIZE) {
buf = malloc(len);//申请一个与传入buf等长的char数组
if (buf == NULL) {
JNU_ThrowOutOfMemoryError(env, NULL);
return ;
}
} else {
buf = stackBuf;
} //前面的代码全是检查参数 fd = GET_FD(this, fid);//获取打开文件的fd
if (fd == -) {
JNU_ThrowIOException(env, "Stream Closed");
nread = -;
} else {
nread = IO_Read(fd, buf, len);//调用IO_Read方法从fd中读取数据
if (nread > ) {
(*env)->SetByteArrayRegion(env, bytes, off, nread, (jbyte *)buf);//将装有数据的char数组里的数据复制到buf中
} else if (nread == -) {
JNU_ThrowIOExceptionWithLastError(env, "Read error");
} else { /* EOF */
nread = -;
}
} if (buf != stackBuf) {
free(buf);//释放临时申请的char数组
}
return nread;
}

先申请一个等长的char数组(系统底层调用无法直接操作从Java应用层面传入的jbyteArray,只能先将数据读取到临时申请的char数组中,再复制到jbyteArray里)

然后调用一个叫做IO_Read的方法从指定的fd里读取数据

这个IO_Read实际上只是一个宏,其定义根据平台有所不同

Windows中的定义位于src/windows/native/java/io/io_util_md.h中,对应于handleRead方法

其源码如下所示:

JNIEXPORT
jint
handleRead(FD fd, void *buf, jint len)
{
DWORD read = ;
BOOL result = ;
HANDLE h = (HANDLE)fd;
if (h == INVALID_HANDLE_VALUE) {
return -;
}
result = ReadFile(h, /* File handle to read */
buf, /* address to put data */
len, /* number of bytes to read */
&read, /* number of bytes read */
NULL); /* no overlapped struct */
if (result == ) {
int error = GetLastError();
if (error == ERROR_BROKEN_PIPE) {
return ; /* EOF */
}
return -;
}
return (jint)read;
}

直接调用Windows平台提供的ReadFile函数完成文件的读取

Linux中的定义位于src/solaris/native/java/io/io_util_md.h中,也对应于handleRead方法:

ssize_t
handleRead(FD fd, void *buf, jint len)
{
ssize_t result;
RESTARTABLE(read(fd, buf, len), result);
return result;
}

其中RESTARTABLE是一个简单的宏,用于发生中断时的重试读取

/*
* Retry the operation if it is interrupted
*/
#define RESTARTABLE(_cmd, _result) do { \
do { \
_result = _cmd; \
} while((_result == -) && (errno == EINTR)); \
} while()

最终还是调用read这个系统调用

再往下就是Linux的内核实现,超出本文的范围了。

现在我们可以对FileInputStream.read方法的实现做一个总结:

  1. 调用native的FileInputStream.readBytes()方法,参数中带有一个byte数组作为读缓冲区

2. 通过jni将这个heap memory中的byte数组的指针传递给jvm

3. jvm开辟一个等长的char类型数组,然后调用IO_Read宏从指定的fd中读取数据将其填满

4. Windows系统中IO_Read的实现是ReadFile,Linux系统中的实现则是read系统调用

5. 调用SetByteArrayRegion方法将step2中的char数组里的数据写到step1中传入的byte数组里,在Java应用的层面来看,此时FileInputStream.read已经读到数据了

6. 释放step2中开辟的临时数组并返回

2. FileChannel.read

第二种方式是使用NIO里FileChannel的read方法,申请一段heap buffer或者direct buffer,然后将文件里的数据灌到buffer里,代码如下所示

        FileInputStream reader = new FileInputStream(fileName);
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 64);//此处也可以调用allocate()方法申请heap buffer
while (reader.getChannel().read(buffer) != -1) {
buffer.clear();
}

FileChannel.read是个抽象方法,其实现在FileChannelImpl中:

    public int read(ByteBuffer dst) throws IOException {
ensureOpen();
if (!readable)
throw new NonReadableChannelException();
synchronized (positionLock) {
int n = 0;
int ti = -1;
try {
begin();
ti = threads.add();
if (!isOpen())
return 0;
do {
n = IOUtil.read(fd, dst, -1, nd);
} while ((n == IOStatus.INTERRUPTED) && isOpen());
return IOStatus.normalize(n);
} finally {
threads.remove(ti);
end(n > 0);
assert IOStatus.check(n);
}
}
}

其中调用了IOUtil.read()方法

    static int read(FileDescriptor fd, ByteBuffer dst, long position,
NativeDispatcher nd)
throws IOException
{
if (dst.isReadOnly())
throw new IllegalArgumentException("Read-only buffer");
if (dst instanceof DirectBuffer)//如果传入的是direct buffer,则直接读取数据
return readIntoNativeBuffer(fd, dst, position, nd); // Substitute a native buffer
ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());//如果是heap buffer,则先申请一块direct buffer,然后读取数据
try {
int n = readIntoNativeBuffer(fd, bb, position, nd);
bb.flip();
if (n > 0)
dst.put(bb);//将临时申请的direct buffer中的数据复制到heap buffer里来
return n;
} finally {
Util.offerFirstTemporaryDirectBuffer(bb);
}
}

IOUtil.read()方法中有一个很有趣的细节:如果传入的缓冲区是heap buffer,则申请一块与原buffer剩余空间等长的direct buffer,然后再读取数据。为什么这样做我们后续会解释

IOUtil.read()方法中又调用了readIntoNativeBuffer()方法

    private static int readIntoNativeBuffer(FileDescriptor fd, ByteBuffer bb,
long position, NativeDispatcher nd)
throws IOException
{
int pos = bb.position();
int lim = bb.limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0); if (rem == 0)
return 0;
int n = 0;
if (position != -1) {
n = nd.pread(fd, ((DirectBuffer)bb).address() + pos,
rem, position);//获取direct buffer的内存地址
} else {
n = nd.read(fd, ((DirectBuffer)bb).address() + pos, rem);//获取direct buffer的内存地址
}
if (n > 0)
bb.position(pos + n);
return n;
}

readIntoNativeBuffer方法中调用了NativeDispatcher.pread与NativeDispatcher.read方法,两者的区别在于文件是否为从头读取,我们只分析参数较多的NativeDispatcher.pread方法,这是一个空方法

    int pread(FileDescriptor fd, long address, int len, long position)
throws IOException
{
throw new IOException("Operation Unsupported");
}

NativeDispatcher.pread方法的具体实现在FileDispatcherImpl.pread中

    int pread(FileDescriptor fd, long address, int len, long position)
throws IOException
{
return pread0(fd, address, len, position);
}

其中又调用了FileDispatcherImpl.pread0方法,这又是一个native方法

    static native int pread0(FileDescriptor fd, long address, int len,
long position) throws IOException;

pread0的具体实现在根据平台不同有不同

Linux下的实现对应于src/solaris/native/sun/nio/ch/FileDispatcherImpl.c

Windows下的实现对应于src/windows/native/sun/nio/ch/FileDispatcherImpl.c

限于篇幅我们只分析Linux中的pread0实现:

JNIEXPORT jint JNICALL
Java_sun_nio_ch_FileDispatcherImpl_pread0(JNIEnv *env, jclass clazz, jobject fdo,
jlong address, jint len, jlong offset)
{
jint fd = fdval(env, fdo);//获取当前的文件的fd
void *buf = (void *)jlong_to_ptr(address);//获取direct buffer的内存地址 return convertReturnVal(env, pread64(fd, buf, len, offset), JNI_TRUE);//调用pread64函数从指定的fd中读取数据
}

其中调用了pread64,跟踪代码后发现实际只是一个宏,对应于pread函数

这个pread函数又是一个系统调用,可以将文件中指定位置的数据写入指定的buf中。

而FileDispatcherImpl.pread0方法传入的direct buffer实际上对应于Java进程中的一块固定区域,直接用pread系统调用来操作是安全的。

总结一下FileChannel.read的实现:

1. 如果传入的buffer是heap buffer,那么申请一块临时的direct buffer执行后续操作

2. 调用native的FileDispatcherImpl.pread0或者FileDispatcherImpl.read0

3. 调用Linux提供的pread或者read系统调用,从文件中读取数据并复制到direct buffer对应的内存区域中

现在我们再回过头来分析一下前面的问题:为什么在IOUtil.read()方法中,一定要将heap buffer转换为direct buffer之后再调用native的FileDispatcherImpl.pread0方法呢?

因为Linux提供的pread或者read系统调用,只能操作一块固定的内存区域。这就意味着只能对direct memory进行操作(heap memory中的对象在经历gc后内存地址会发生改变,如果一定要直接写入heap memory,就必须要将这个对象pin住,但是hotspot不提供单个对象层面的object pinning,一定要pin的话就只能暂时禁用gc了,也就是把整个Java堆都给pin住,这显然代价太高了。)所以heap buffer要么直接在上层就转成direct buffer,要么像前面介绍的FileInputStream.read中那样在jvm层面申请一个临时byte数组再调用read/pread方法,然后将临时byte数组里的数组写入到heap buffer里。出于统一逻辑的角度考虑,当然是直接申请一个临时direct buffer对象的好。

3. FileChannel.map

第三种方式是使用NIO里FileChannel的map方法,也就是之前博客里介绍过的内存映射文件,然后像直接访问内存一样的读取文件即可。范例代码如下所示:

    private static void mmapReadTest(String filenamm) throws IOException {
Stopwatch stopwatch = Stopwatch.createStarted(); File file = new File(filenamm);
long len = file.length(); byte[] buf = new byte[1024 * 64]; for (int i = 0; i <= len / Integer.MAX_VALUE; i++) {
//一次不能映射超过Integer.MAX_VALUE大小的数据,因此需要对文件做分段映射
long position = (long) i * Integer.MAX_VALUE;
long size = len - (long) i * Integer.MAX_VALUE > Integer.MAX_VALUE ?
Integer.MAX_VALUE :
len - (long) i * Integer.MAX_VALUE;
if (size <= 0) {
break;
} MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r").getChannel()
.map(FileChannel.MapMode.READ_ONLY, position, size); //循环读取文件
while (mappedByteBuffer.remaining() >= buf.length) {
mappedByteBuffer.get(buf);
}
if (mappedByteBuffer.hasRemaining()) {
mappedByteBuffer.get(buf, 0, mappedByteBuffer.remaining());
}
}
System.out.println(stopwatch);
}

先看FileChannel.map方法的实现:

    public MappedByteBuffer map(MapMode mode, long position, long size)
throws IOException
{
//删除无关代码 int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
// If no exception was thrown from map0, the address is valid
addr = map0(imode, mapPosition, mapSize);//尝试调用map0方法创建内存映射文件
} catch (OutOfMemoryError x) {
// An OutOfMemoryError may indicate that we've exhausted memory
// so force gc and re-attempt map
System.gc();//如果发生OOM异常,强制gc,睡眠100ms后重试
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(imode, mapPosition, mapSize);//重试创建内存映射文件
} catch (OutOfMemoryError y) {
// After a second OOME, fail
throw new IOException("Map failed", y);
}
} // On Windows, and potentially other platforms, we need an open
// file descriptor for some mapping operations.
FileDescriptor mfd;
try {
mfd = nd.duplicateForMapping(fd);
} catch (IOException ioe) {
unmap0(addr, mapSize);
throw ioe;
} assert (IOStatus.checkAll(addr));
assert (addr % allocationGranularity == 0);
int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
if ((!writable) || (imode == MAP_RO)) {
return Util.newMappedByteBufferR(isize,
addr + pagePosition,
mfd,
um);
} else {
return Util.newMappedByteBuffer(isize,
addr + pagePosition,
mfd,
um);
}
} finally {
threads.remove(ti);
end(IOStatus.checkAll(addr));
}
}

可以看到调用了FileChannelImpl.map0()方法,这又是一个native方法

    // Creates a new mapping
private native long map0(int prot, long position, long length)
throws IOException;

FileChannelImpl.map0()的Linux版本实现位于src/solaris/native/sun/nio/ch/FileChannelImpl.c

Windows版本实现位于src/windows/native/sun/nio/ch/FileChannelImpl.c

本文只介绍Linux版本的实现:

JNIEXPORT jlong JNICALL
Java_sun_nio_ch_FileChannelImpl_map0(JNIEnv *env, jobject this,
jint prot, jlong off, jlong len)
{
void *mapAddress = ;
jobject fdo = (*env)->GetObjectField(env, this, chan_fd);
jint fd = fdval(env, fdo);
int protections = ;
int flags = ; if (prot == sun_nio_ch_FileChannelImpl_MAP_RO) {
protections = PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_RW) {
protections = PROT_WRITE | PROT_READ;
flags = MAP_SHARED;
} else if (prot == sun_nio_ch_FileChannelImpl_MAP_PV) {
protections = PROT_WRITE | PROT_READ;
flags = MAP_PRIVATE;
} mapAddress = mmap64(//系统调用
, /* Let OS decide location */
len, /* Number of bytes to map */
protections, /* File permissions */
flags, /* Changes are shared */
fd, /* File descriptor of mapped file */
off); /* Offset into file */ if (mapAddress == MAP_FAILED) {
if (errno == ENOMEM) {
JNU_ThrowOutOfMemoryError(env, "Map failed");
return IOS_THROWN;
}
return handle(env, -, "Map failed");
} return ((jlong) (unsigned long) mapAddress);//将mmap64生成的内存映射地址返回给Java应用,jdk会将其包装成ByteBuffer的形式给应用使用
}

前后的代码都比较无聊,关键之处是这个mmap64函数,不出意外的,mmap64也是个宏,对应于Linux的mmap系统调用

native的FileChannelImpl.map0()方法的返回值为long,代表了文件的内存映射的起始位置,但是应用无法根据内存地址直接操作direct memory,需要将其包装为ByteBuffer的格式

这一过程是由FileChannel.map方法中调用的Util.newMappedByteBufferR与Util.newMappedByteBuffer方法完成的

我们看一下其中的Util.newMappedByteBuffer方法:

static MappedByteBuffer newMappedByteBuffer(int size, long addr,
FileDescriptor fd,
Runnable unmapper)
{
MappedByteBuffer dbb;
if (directByteBufferConstructor == null)
initDBBConstructor();
try {
dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance(
new Object[] { new Integer(size),
new Long(addr),
fd,
unmapper });
} catch (InstantiationException |
IllegalAccessException |
InvocationTargetException e) {
throw new InternalError(e);
}
return dbb;
}

这里使用反射来创建DirectByteBuffer实例的原因是因为java.nio.DirectByteBuffer与sun.nio.ch.Util不在同一个包里,无法直接访问。

最后我们调用的MappedByteBuffer.get()从内存映射文件里读取数据的实际实现是DirectByteBuffer.get()方法

    public ByteBuffer get(byte[] dst, int offset, int length) {
if (((long)length << 0) > Bits.JNI_COPY_TO_ARRAY_THRESHOLD) {
checkBounds(offset, length, dst.length);
int pos = position();
int lim = limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
if (length > rem)
throw new BufferUnderflowException(); Bits.copyToArray(ix(pos), dst, arrayBaseOffset,
(long)offset << 0,
(long)length << 0);
position(pos + length);
} else {
super.get(dst, offset, length);
}
return this;
}

大概意思是先计算出需要get的数据在直接内存中的偏移量,以及数据的长度,然后调用Bits.copyToArray将这些数据从直接内存拷贝到传入的byte数组中。

Bits.copyToArray的实现如下:

    static void copyToArray(long srcAddr, Object dst, long dstBaseOffset, long dstPos,
long length)
{
long offset = dstBaseOffset + dstPos;
while (length > 0) {
long size = (length > UNSAFE_COPY_THRESHOLD) ? UNSAFE_COPY_THRESHOLD : length;
unsafe.copyMemory(null, srcAddr, dst, offset, size);//将数据从direct memory拷贝到heap memory
length -= size;
srcAddr += size;
offset += size;
}
}

具体的拷贝操作是由Unsafe.copyMemory来实现的,里面的黑魔法比较复杂,这里就不做进一步探究了。

总结一下FileChannel.map的实现:

1. 调用native的FileChannelImpl.map0()方法

2. 调用系统调用mmap,创建内存映射文件,返回文件在内存中映射区域的起始位置。这可以理解为direct memory中的一个指针

3. 用Util.newMappedByteBufferR与Util.newMappedByteBuffer将这个指针包装为一个DirectByteBuffer对象并返回给用户

4. 用户调用DirectByteBuffer.get()方法的时候,jvm会尝试将direct memory中的数据复制到用户指定的byte数组里

5. 第一次访问会触发缺页中断,操作系统会尝试在swap空间里寻找数据,如果找不到(这个文件从未被载入内存)则触发磁盘的读操作,文件数据会被直接复制到物理内存中(read/pread这些系统调用还需要经过kernel中维护的文件缓冲区),复制完毕后direct memory中也可以直接访问这些数据了,现在可以继续执行step4中的复制操作。

6. 如果物理内存不够用了,则会通过虚拟内存机制将暂时不用的物理页面交换到硬盘的虚拟内存中。

4. 测试性能

我手上有一台vps,1 core+ 512M内存,配置看起来相当寒酸,但是硬盘性能倒是不错:读2g/s,写700m/s,估计底层用的是pcie接口的ssd

创建了一个大小为6.9G的文件,并用上面提到的三种方式遍历这个文件(只读取数据不做任何处理)

性能数据如下所示:

a. FileInputStream.read,平均耗时4.3秒

b. FileChannel.read,使用direct memory作为缓冲区,平均耗时3.6秒

c. FileChannel.read,使用heap memory作为缓冲区,平均耗时4.7秒

d. FileChannel.map,由于VPS内存有限,我将文件分成64MB大小的区块进行映射,但是平均耗时在7.8秒

分析:

a/c比b要慢,是因为b只将文件数据读到了direct memory,而a与c还要将这些direct memory复制到heap memory里,多出了一些开销。

FileChannel.map最慢,可能是因为vps的内存过小,影响了mmap的发挥吧。

参考资料:

Java NIO中,关于DirectBuffer,HeapBuffer的疑问?

深入浅出MappedByteBuffer

Java IO 学习(五)跟踪三个文件IO方法的调用链的更多相关文章

  1. Java NIO 学习笔记(七)----NIO/IO 的对比和总结

    目录: Java NIO 学习笔记(一)----概述,Channel/Buffer Java NIO 学习笔记(二)----聚集和分散,通道到通道 Java NIO 学习笔记(三)----Select ...

  2. Java NIO 学习笔记(四)----文件通道和网络通道

    目录: Java NIO 学习笔记(一)----概述,Channel/Buffer Java NIO 学习笔记(二)----聚集和分散,通道到通道 Java NIO 学习笔记(三)----Select ...

  3. Java NIO 学习笔记(三)----Selector

    目录: Java NIO 学习笔记(一)----概述,Channel/Buffer Java NIO 学习笔记(二)----聚集和分散,通道到通道 Java NIO 学习笔记(三)----Select ...

  4. Java命令学习系列(三)——Jmap

    Java命令学习系列(三)——Jmap 2015-05-16 分类:Java 阅读(479) 评论(0) Jmap jmap是JDK自带的工具软件,主要用于打印指定Java进程(或核心文件.远程调试服 ...

  5. 八、Android学习第七天——XML文件解析方法(转)

    (转自:http://wenku.baidu.com/view/af39b3164431b90d6c85c72f.html) 八.Android学习第七天——XML文件解析方法 XML文件:exten ...

  6. “全栈2019”Java多线程第五章:线程睡眠sleep()方法详解

    难度 初级 学习时间 10分钟 适合人群 零基础 开发语言 Java 开发环境 JDK v11 IntelliJ IDEA v2018.3 文章原文链接 "全栈2019"Java多 ...

  7. Java程序设计学习笔记(三)—— IO

    时间:2016-3-24 11:02 --IO流(Input/Output)     IO流用来处理设备之间的数据传输.    Java对数据的操作是通过流的方式.    Java对于操作流的对象都在 ...

  8. Java NIO学习系列四:NIO和IO对比

    前面的一些文章中我总结了一些Java IO和NIO相关的主要知识点,也是管中窥豹,IO类库已经功能很强大了,但是Java 为什么又要引入NIO,这是我一直不是很清楚的?前面也只是简单提及了一下:因为性 ...

  9. JAVA基础学习day21--IO流三-File、Properties、PrintWriter与合并、分割流

    一.File 1.1.File概述 文件和目录路径名的抽象表示形式. 用户界面和操作系统使用与系统相关的路径名字符串 来命名文件和目录.此类呈现分层路径名的一个抽象的.与系统无关的视图.抽象路径名 有 ...

随机推荐

  1. 剑指Offer - 九度1214 - 丑数

    剑指Offer - 九度1214 - 丑数2013-11-21 21:06 题目描述: 把只包含因子2.3和5的数称作丑数(Ugly Number).例如6.8都是丑数,但14不是,因为它包含因子7. ...

  2. 《Cracking the Coding Interview》——第17章:普通题——题目8

    2014-04-28 23:35 题目:最大子数组和问题. 解法:O(n)解法. 代码: // 17.8 Find the consecutive subarray with maximum sum ...

  3. 《Cracking the Coding Interview》——第5章:位操作——题目8

    2014-03-19 06:33 题目:用一个byte数组来模拟WxH的屏幕,每个二进制位表示一个像素.请设计一个画水平线的函数. 解法:一个点一个点地画就可以了.如果要优化的话,其实可以把中间整字节 ...

  4. (原)SpringMVC全注解不是你们那么玩的

    前言:忙了段时间,忙得要死要活,累了一段时间,累得死去活来. 偶尔看到很多零注解配置SpringMVC,其实没有根本的零注解. 1)工程图一张: web.xml在servlet3.0里面已经被注解完全 ...

  5. JMeter学习笔记(七) 导出文件接口测试

    导出文件接口,其实跟下载文件接口的测试类似,主要就是执行接口导出文件后保存到本地. 下载文件接口测试,参考文档:https://www.cnblogs.com/xiaoyu2018/p/1017830 ...

  6. python 之发送邮件服务[原著] 海瑞博客

    Python 发送邮件 使用默认的django的发送邮件,只适用于单邮箱. 作者:海瑞博客 http://www.hairuinet.com/ setting中配置 # send e-mail EMA ...

  7. ASP.NET Core 认证与授权[1]:初识认证 (笔记)

    原文链接:  https://www.cnblogs.com/RainingNight/p/introduce-basic-authentication-in-asp-net-core.html 在A ...

  8. linux备忘录-vi和vim

    知识点 vi的三种模式 一般模式 按 ESC 可回到一般模式 相关按键 j 代表 向下按钮 k 代表 向上按钮 h 代表 向左按钮 l 代表 向右按钮 20j 等代表 向下移动20行 Ctrl + f ...

  9. shell中的>&1和 >&2是什么意思?

    当初在shell中, 看到">&1"和">&2"始终不明白什么意思.经过在网上的搜索得以解惑.其实这是两种输出. 在 shell 程 ...

  10. B - Help Jimmy

    B - Help Jimmy Time Limit: 1000/1000MS (C++/Others) Memory Limit: 65536/65536KB (C++/Others) Problem ...