JVM探针与字节码技术
JVM探针是自jdk1.5以来,由虚拟机提供的一套监控类加载器和符合虚拟机规范的代理接口,结合字节码指令能够让开发者实现无侵入的监控功能。如:监控生产环境中的函数调用情况或动态增加日志输出等等。虽然在常规的业务中不会有太多用武之地,但是作为一项高级的技术手段也应该是资深开发人员的必备技能之一。同时,它也是企业级开发和生产环境部署不可或缺的技术方案,是对当下流行的APM的一种补充,因为使用探针技术能够实现比常规APM平台更细粒度的监控。
哪些方面适合使用探针技术:
(1) 如果你发现生产环境上有些问题无法在测试或开发环境中复现
(2) 如果你希望在不修改源码的情况下为你的应用添加一些输出日志
(3) 如果在刚发布的生产包中发现了一个bug,而你又不希望被它阻断,希望有一个临时的补救措施
一、JVM探针:Instrumentation
使用探针只需要一条附加选项:-javaagent:<jar 路径>[=<选项>],作为探针(代理)的jar包必须满足两个条件:1. MANIFEST.MF文件需要增加Premain-Class项,说明启动类。2. 启动类必须声明一个静态函数,它的入参是: String和Instrumentation。因此,一个常见的启动类可能像这样:
package aa.bb.cc; public class PremainAgent {
public static void premain(String agentArgs, Instrumentation inst) {
// TODO
}
}
MANIFEST.MF
premain-class: aa.bb.cc.PremainAgent
如果使用maven作为构建工具,需要在pom文件中添加构建插件
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifestEntries>
<premain-class>aa.bb.cc.PremainAgent</premain-class>
</manifestEntries>
</archive>
</configuration>
</plugin>
如果你还引入了其它依赖希望同时打包,那么你应该使用assembly插件替代
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>2.4</version>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifestEntries>
<Premain-Class><package>.PremainAgent</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
两个重要的类
Instrumentation: 由JDK提供的一个探针类,它会负责加载用户自定义的ClassFileTransformer
ClassFileTransformer: 字节码转换类,jvm在加载class文件前会先调用它,对所有类加载器有效
具体用法稍后会做详细介绍。
总结:JVM探针只是提供了一种让开发人员能够在类加载加载class文件前主动介入的一种方法,具体如何操作需要开发人员了解Java虚拟机规范以及字节码的相关知识。
二、栈帧与指令集
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。并且在Java编译为Class文件时,就已经确定了该方法所需要分配的局部变量表的最大容量。局部变量表类似一个数组结构,虚拟机在访问局部变量表的时候会使用下标作为引用,普通方法的局部变量表中第0位索引默认是用于传递方法所属对象实例的引用this。
操作数栈(Operand Stack)和局部变量表一样,在编译时期就已经确定了该方法所需要分配的局部变量表的最大容量。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其它方法的时候是通过操作数栈来进行参数传递的。
动态链接(Dynamic Linking)每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支付方法调用过程中的动态连接。在类加载阶段中的解析阶段会将符号引用转为直接引用,这种转化也称为静态解析。另外的一部分将在每一次运行时期转化为直接引用,这部分称为动态连接。
返回地址:当一个方法开始执行后,只有2种方式可以退出这个方法,方法返回指令和异常退出。无论采用任何退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
JVM指令集并非是对Java语句的直接翻译,由于指令只使用1个字节表示,所以指令集最多只能包含256种指令。因此,一条Java语句一般会对应多条底层指令。每一条指令都有与之对应的助记符,我们可以通过官方资料查看它们对应关系:https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html。为了帮助大家更加直观的理解字节码指令,我将通过三个用例分别解释。
从一个简单的加法函数开始,我们可以使用javac将.java文件编译成.class,再通过javap -c查看它的字节码文件
public int add(int x, int y) {
return x + y;
}
1 public add(II)I
2 ILOAD 1 // 将局部变量表中#1变量入栈
3 ILOAD 2 // 将局部变量表中#2变量入栈
4 IADD // 调用整型数相加(两个数出栈,再将结果入栈)
5 IRETURN // 返回栈顶的结果
6 MAXSTACK = 2 // 最大栈数2
7 MAXLOCALS = 3 // 最大本地变量数3
第一行是它的函数签名,2~7行的注释分别是对指令的解释。ILOAD,IADD,IRETURN分别是整型数的入栈,加法和返回操作。大家可以将add方法修改为静态函数后重新编译,看看MAXLOCALS是否有变化。
接下来我们把函数变得复杂一些,尝试对函数的执行时间做一个计算并输出
public int add(int x, int y) {
long t = System.nanoTime();
int ret = x + y;
t = System.nanoTime() - t;
System.out.println(t);
return ret;
}
1 public add(II)I
2 INVOKESTATIC java/lang/System.nanoTime ()J // 调用静态函数,结果long入栈
3 LSTORE 3 // 将栈顶的long保存到局部变量#3
4 ILOAD 1
5 ILOAD 2
6 IADD
7 ISTORE 5 // 将栈顶的int保存到局部变量#5
8 INVOKESTATIC java/lang/System.nanoTime ()J
9 LLOAD 3 // 局部变量#3入栈
10 LSUB // 从栈顶弹出两个long相减
11 LSTORE 3 // 结果保存到变量#3
12 GETSTATIC java/lang/System.out : Ljava/io/PrintStream; // 获取静态引用
13 LLOAD 3 // 局部变量#3入栈
14 INVOKEVIRTUAL java/io/PrintStream.println (J)V // 调用函数
15 ILOAD 5 // 局部变量#5入栈
16 IRETURN
17 MAXSTACK = 4
18 MAXLOCALS = 6
第2行结尾的J表示函数返回值是long类型。第14行结尾的V表示println函数的返回值是void。第12行到第14行的指令对应代码的System.out.println(t),特别需要注意的是INVOKEVIRTUAL指令实际上需要从操作数栈获取两个数,第一个数是在执行了GETSTATIC后入栈的对象引用。
我们再次修改函数,这一次我们引入比较和循环语句,尽管代码的逻辑不太正常,但这并不妨碍我们理解
public int add(int x, int y) {
if(x > 1) {
return x + y;
}
for(int i = 0; i < y; i++) {
x ++;
}
return x - y;
}
1 public add(II)I
2 ILOAD 1
3 ICONST_1 // 将一个常整型数1入栈
4 IF_ICMPLE L0 // 比较如果操两个操作数是小于等于的关系则成立,否则跳转到L0的位置继续
5 ILOAD 1
6 ILOAD 2
7 IADD
8 IRETURN
9 L0
10 ICONST_0 // 将常整型数0入栈
11 ISTORE 3 // 栈顶数保存到局部变量#3
12 L1
13 ILOAD 3
14 ILOAD 2
15 IF_ICMPGE L2 // 比较栈顶的两个操作数是否是大于等于的关系,如果不成立则跳转到L2
16 IINC 1 1 // 局部变量#1 自增1
17 IINC 3 1 // 局部变量#3 自增1
18 GOTO L1 // 跳转到L1执行
19 L2
20 ILOAD 1
21 ILOAD 2
22 ISUB
23 IRETURN
24 MAXSTACK = 2
25 MAXLOCALS = 4
当我们使用字节码直接操作虚拟机中的底层代码的时候,基本上就是通过改变局部变量表和操作数栈来改变程序的逻辑。还记得根据Java虚拟机规范,MAXSTACK和MAXLOCALS是在.java文件被编译成.class就被确定下来的吗,如果我们要对方法做出修改势必会引入新的局部变量,这时就难免需要对MAXSTACK和MAXLOCALS做重新计算。好在目前流行的字节码框架已经可以自动帮助我们完成这项任务。
三、ASM框架
ASM是一个比较硬核的字节码框架,也是转换效率最高的工具。下面是常用类的介绍:
1. ClassReader
按照Java虚拟机规范(JVMS)中定义的方式来解析class文件中的内容,在遇到合适的字段时调用ClassVisitor中相对应的方法。
ClassReader(final byte[] classFile)
构造方法,通过class字节码数据加载
ClassReader(final String className) throws IOException
通过class全路径名从ClassLoader加载
2. ClassVisitor
java中类的访问者,提供一系列方法由ClassReader调用。调用的顺序如下:visit -> visitSource -> visitModule -> visitNestHost -> visitOuterClass -> visitAnnotation -> visitTypeAnnotation -> visitAttribute -> visitNestMember -> visitPermittedSubclass -> visitInnerClass -> visitRecordComponent -> visitField -> visitMethod -> visitEnd
3. ClassWriter
ClassVisitor的子类,通过它生成最后的字节码。并且它可以帮助重新计算MAXSTACK和MAXLOCALS
4. ModuleVisitor
Java中模块的访问者,作为ClassVisitor.visitModule方法的返回值
5. AnnotationVisitor
Java中注解的访问者,作为ClassVisito中visitTypeAnnotation和visitTypeAnnotation的返回值
6. FieldVisitor
Java中字段的访问者,作为ClassVisito.visitField的返回值
7. MethodVisitor
Java中方法的访问者,作为ClassVisito.visitMethod的返回值
- visitMethodInsn 方法调用指令
- visitVarInsn 局部变量调用指令
- visitInsn(int) 访问一个零参数要求的字节码指令,如LSUB
- visitLdcInsn 把一个常量放到栈顶
- visitInvokeDynamicInsn 动态方法调用
- visitFieldInsn 调用/访问某个字段
8. AnalyzerAdapter
MethodVisitor的子类,使用它重新计算最大操作数栈(MAXSTACK)
9. LocalVariablesSorter
MethodVisitor的子类,使用它重新计算局部变量表(MAXLOCALS)的索引
- newLocal 创建局部变量
通过IDEA的Plugins安装ASM Bytecode Viewer Support Kotlin,我们可以借助这个插件来帮助我们生成大部分代码,具体用法这里就赘述了。
总结:有了以上知识基础,我们可以完成一个简单的demo来感受探针和字节码技术的强大。
一个计算函数执行时间的完整用例
1. 在IDEA中创建一个典型的maven工程
2.编写pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<groupId>com.learnhow.study</groupId>
<version>1.0</version>
<packaging>jar</packaging>
<artifactId>agent</artifactId> <dependencies>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.2</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm-commons</artifactId>
<version>9.2</version>
</dependency>
</dependencies> <build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>[your jdk version]</source>
<target>[your jdk version]</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>2.4</version>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifestEntries>
<Premain-Class>[your package].PremainAgent</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
带[]的部分请换成你的本地环境。
3.PremainAgent类
public class PremainAgent {
public static void premain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new XClassFileTransformer());
}
}
4.XClassFileTransformer类
public class XClassFileTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
try {
ClassReader cr = new ClassReader(classfileBuffer);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
cr.accept(new NanoTimerClassVisitor(cw), ClassReader.SKIP_DEBUG);
byte[] cc = cw.toByteArray();
return cc;
} catch (IOException e) { }
return null;
}
}
transform方法返回null或者new byte[0]表示对当前字节码文件不进行修改。ClassWriter.COMPUTE_MAXS表示框架会自动计算MAXSTACK和MAXLOCALS,ClassReader.SKIP_DEBUG表示当字节码中包含调试信息的时候,会忽略不会触发回调。
5.NanoTimerClassVisitor类
public class NanoTimerClassVisitor extends ClassVisitor {
private String className; public NanoTimerClassVisitor(ClassVisitor classVisitor) {
super(ASM9, classVisitor);
} @Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
this.className = name;
super.visit(version, access, name, signature, superName, interfaces);
} @Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
if (Objects.nonNull(mv) && !name.equals("<init>") && !name.equals("<clinit>")) {
NanoTimerMethodVisitor methodVisitor = new NanoTimerMethodVisitor(mv, className, access, name, descriptor);
return methodVisitor.refactor();
}
return mv;
} class NanoTimerMethodVisitor extends MethodVisitor {
private AnalyzerAdapter analyzerAdapter;
private LocalVariablesSorter localVariablesSorter;
private int timeOpcode;
private int outOpcode;
private String className;
private int methodAccess;
private String methodName;
private String methodDescriptor; public NanoTimerMethodVisitor(MethodVisitor methodVisitor, String className, int methodAccess,
String methodName, String methodDescriptor) {
super(ASM9, methodVisitor);
this.className = className;
this.methodAccess = methodAccess;
this.methodName = methodName;
this.methodDescriptor = methodDescriptor;
// 使用AnalyzerAdapter计算最大操作数栈
analyzerAdapter = new AnalyzerAdapter(className, methodAccess, methodName, methodDescriptor, this);
// LocalVariablesSorter重新计算局部变量的索引并自动更新字节码中的索引引用
localVariablesSorter = new LocalVariablesSorter(methodAccess, methodDescriptor, analyzerAdapter);
} public MethodVisitor refactor() {
return localVariablesSorter;
} @Override
public void visitCode() {
super.visitCode();
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false);
timeOpcode = localVariablesSorter.newLocal(Type.LONG_TYPE);
mv.visitVarInsn(LSTORE, timeOpcode);
} @Override
public void visitInsn(int opcode) {
if ((opcode >= IRETURN && opcode <= RETURN) || opcode == ATHROW) {
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false);
mv.visitVarInsn(LLOAD, timeOpcode);
mv.visitInsn(LSUB);
mv.visitVarInsn(LSTORE, timeOpcode); mv.visitLdcInsn(className + "." + methodName + "(ns):");
outOpcode = localVariablesSorter.newLocal(Type.getType(String.class));
mv.visitVarInsn(ASTORE, outOpcode); mv.visitVarInsn(ALOAD, outOpcode);
mv.visitVarInsn(LLOAD, timeOpcode);
mv.visitInvokeDynamicInsn("makeConcatWithConstants", "(Ljava/lang/String;J)Ljava/lang/String;", new Handle(Opcodes.H_INVOKESTATIC, "java/lang/invoke/StringConcatFactory", "makeConcatWithConstants", "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;", false), new Object[]{"\u0001\u0001"});
mv.visitVarInsn(ASTORE, outOpcode); mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitVarInsn(ALOAD, outOpcode);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
super.visitInsn(opcode);
}
}
}
6. 通过assembly插件对项目进行打包生成:agent-1.0-jar-with-dependencies.jar
7. 运行一个目标项目,并添加虚拟机指令-javaagent,就可以看到执行效果
如何查看生成后的代码
计算函数执行时间是一个非常简单的功能,我们很容易一次性写正确。但是如果需要代理的逻辑比较复杂,而探针程序又不像普通程序一样方便做断点调试。我们如何才能够很方便知道生成的代码是否正确呢?这里告诉大家一个诀窍。回到我们XClassFileTransformer类,增加两行代码:
1 public class XClassFileTransformer implements ClassFileTransformer {
2 @Override
3 public byte[] transform(ClassLoader loader,
4 String className,
5 Class<?> classBeingRedefined,
6 ProtectionDomain protectionDomain,
7 byte[] classfileBuffer) throws IllegalClassFormatException {
8 try {
9 ClassReader cr = new ClassReader(classfileBuffer);
10 ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
11 cr.accept(new NanoTimerClassVisitor(cw), ClassReader.SKIP_DEBUG);
12 byte[] cc = cw.toByteArray();
13 FileOutputStream fos = new FileOutputStream("./cc.class");
14 fos.write(cc);
15 return cc;
16 } catch (IOException e) {
17
18 }
19 return null;
20 }
21 }
第13、14行代码的功能是将生成的字节码输出到本地文件中,然后我们通过IDEA打开这个.class文件,看看新增加的代码是否如我们预期的那样。
总结:JVM代理发生在类加载器加载.class文件前,因此我们能够动态修改字节码。通过ASM这类字节码框架,使得开发人员即使对字节码指令不是很熟悉依然能够操作。当然,Java的探针技术除了和被代理的项目同时启动以外还提供了一种热部署的方案,受篇幅限制不再赘述,如果大家有兴趣可以给我留言。
JVM探针与字节码技术的更多相关文章
- JVM性能优化--字节码技术
一.字节码技术应用场景 AOP技术.Lombok去除重复代码插件.动态修改class文件等 二.字节技术优势 Java字节码增强指的是在Java字节码生成之后,对其进行修改,增强其功能,这种方式相当于 ...
- jvm系列四类加载与字节码技术
四.类加载与字节码技术 1.类文件结构 首先获得.class字节码文件 方法: 在文本文档里写入java代码(文件名与类名一致),将文件类型改为.java java终端中,执行javac X:...\ ...
- JVM:类加载与字节码技术-2
JVM:类加载与字节码技术-2 说明:这是看了 bilibili 上 黑马程序员 的课程 JVM完整教程 后做的笔记 内容 这部分内容在上一篇笔记中: 类文件结构 字节码指令 编译期处理 类加载阶段 ...
- JVM:类加载与字节码技术-1
JVM:类加载与字节码技术-1 说明:这是看了 bilibili 上 黑马程序员 的课程 JVM完整教程 后做的笔记 内容 类文件结构 字节码指令 下面的内容在后续笔记中: 编译期处理 类加载阶段 类 ...
- 使用java动态字节码技术简单实现arthas的trace功能。
参考资料 ASM 系列详细教程 编译时,找不到asm依赖 用过[Arthas]的都知道,Arthas是alibaba开源的一个非常强大的Java诊断工具. 不管是线上还是线下,我们都可以用Arthas ...
- Java 动态字节码技术
对 Debug 的好奇 初学 Java 时,我对 IDEA 的 Debug 非常好奇,不止是它能查看断点的上下文环境,更神奇的是我可以在断点处使用它的 Evaluate 功能直接执行某些命令,进行一些 ...
- 字节码技术---------动态代理,lombok插件底层原理。类加载器
字节码技术应用场景 AOP技术.Lombok去除重复代码插件.动态修改class文件等 字节技术优势 Java字节码增强指的是在Java字节码生成之后,对其进行修改,增强其功能,这种方式相当于对应用 ...
- 图解jvm--(三)类加载与字节码技术
类加载与字节码技术 1.类文件结构 根据 JVM 规范,类文件结构如下 ClassFile { u4 magic; //魔数 u2 minor_version; //小版本号 u2 major_ver ...
- <JVM中篇:字节码与类的加载篇>04-再谈类的加载器
笔记来源:尚硅谷JVM全套教程,百万播放,全网巅峰(宋红康详解java虚拟机) 同步更新:https://gitee.com/vectorx/NOTE_JVM https://codechina.cs ...
随机推荐
- 翻译Go Blog: 常量
常量 Pob Pike 2014年8月24日 原文 介绍 Go是一门静态语言,它不允许不同数字类型间的操作.你不能将一个浮点数(float64)和一个整数(int)相加,也不能将一个32位整数(int ...
- vivo全球商城时光机 - 大型促销活动保障利器
一.背景 官网商城在双11.双12等大促期间运营同学会精心设计许多给到用户福利的促销活动,当促销活动花样越来越多后就会涉及到很多的运营配置工作(如指定活动有效期,指定活动启停状态,指定活动参与商品等等 ...
- Python3-sqlalchemy-orm 创建关联表带外键并插入数据
#-*-coding:utf-8-*- #__author__ = "logan.xu" import sqlalchemy from sqlalchemy import crea ...
- 本地yum源搭建
2021/07/15 1.挂载 # 创建挂载目录 mkdir /mnt/cdrom # 挂载 mount -t iso9660 /dev/cdrom /mnt/cdrom 2.修改 yum 源配置# ...
- ES6中函数调用自身需要注意的问题
在传统的递归调用中,可以采用如下方式 function sum(n) { return sum(n - 1) + n;} 但如今es6盛行,为了保持代码一致性,可以采用两种解决方式. 第一种,将thi ...
- Session原理、生命周期及购物车功能的实现
在WEB开发中,服务器可以为每个用户浏览器创建一个会话对象(session对象),注意:一个浏览器独占一个session对象(默认情况下).因此,在需要保存用户数据(保存该浏览器(会话)的相关信息)时 ...
- Django使用富文本编辑器ckediter
1 - 安装 pip install django-ckeditor 2 - 注册APP ckeditor 3 - 由于djang-ckeditor在ckeditor-init.js文件中使用了JQu ...
- 20210713考试-2021noip14
T1 队长快跑 #include<bits/stdc++.h> using namespace std; const int N=1e6+5,INF=0x7fffffff; int n,a ...
- C#提取程序的图标
需要添加对System.Management.dll的引用 ,并且不要忘记导入下面的名称空间. using System.Management; 将ListView和 ImageList控件从可视 ...
- Linux学习笔记--快捷键
桌面 ALT+空格 打开窗口菜单 ALT+F1 聚焦到桌面左侧任务导航栏,可按上下键导航 ALT+F2 运行命令 ALT+F4 关闭窗口 ALT+TAB 切换程序窗口 PRINT 桌面截图 S ...