第四章 Java内存模型

文章目录

  • 第四章 Java内存模型
  • 一、物理机的并发问题
    • 1.硬件的效率问题
    • 2.缓存一致性问题
    • 3.代码乱序执行优化问题
  • 二、Java 内存模型
    • 1.概念
    • 2.Java 内存模型的组成
    • 3.JVM 内存操作的并发问题
  • 三、Java 内存间的交互操作
    • 1.交互操作流程
    • 2.内存交互的基本操作
  • 四、Java 内存间运行规则
    • 1.内存交互基本操作的 3 个特性
    • 2.happens-before 关系
    • 3.内存屏障
    • 4.volatile 变量的特殊规则
    • 5.final 变量的特殊规则
    • 6.synchronized 变量的特殊规则
    • 7.long 和 double 变量的特殊规则
  • 参考链接

一、物理机的并发问题

在介绍 Java 内存模型之前,我们先了解以下物理计算机中的并发问题,理解这些问题可以搞清楚内存模型产生的背景。物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机的解决方案对虚拟机的实现有相当的参考意义

1.硬件的效率问题

计算机处理器处理绝大多数运行任务都不可能只靠处理器计算来完成,还需要处理器与内存进行交互,如读取运算数据、存储运算结果等

由于计算机的存储设备与处理器的运算速度有几个数量级的差距,为了避免处理器白白等待内存完成读写操作,现代计算机系统加入了一层读写速度尽可能接近处理器运算速度的高速缓存

缓存作为内存和处理器之间的缓冲,可以将运算需要使用到的数据复制到缓存中,从而提高读写速度,当运算结束后再将数据从缓存中同步回内存

2.缓存一致性问题

基于高速缓存的存储系统交互很好地解决了处理器运算速度和内存读写速度的矛盾,但是也因此带来了一个新问题:缓存一致性

在多处理器(或单处理器多核)的系统中,每个处理器(核)都有自己的高速缓存,此外还共享同一块主内存(Main Memory)

当多个处理器的运算任务都涉及同一块主内存区域时,可能导致各自的缓存数据不一致。为次,需要各个处理器访问主内存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性

3.代码乱序执行优化问题

为了让处理器内部的运算单元能被充分利用,提高运算效率,处理器可能会对输入的代码进行乱序执行

处理器会在计算之后将执行的结果重组,乱序优化可以保证在单线程下该执行结果与顺序执行的结果是一致的,但不保证程序中各个语句计算的先后顺序与输入代码中的一致


乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化。在单核时代,处理器可以保证做出的优化不会导致执行结果远离预期目标,但在多核环境下却并非如此

在多核环境下,假设存在一个核的计算任务依赖另一个核的计算任务的中间结果,这个时候处理器进行了乱序执行,最终的结果可能会偏离预期,我们来看个例子:


CPU 的 core2 中的逻辑 B 依赖 core1 中的逻辑 A 执行后将 flag 设为 true:

  • 正常情况下,逻辑 A 执行完之后才能执行逻辑 B
  • 在处理器乱序执行的情况下,有可能导致 flag 提前被设置为 true,导致不需要先执行逻辑 A 就可以直接执行逻辑 B 。那么如果逻辑 A 本来是不满足条件的,也就是说,flag 也无法被设置为 true,逻辑 B 也不该被执行

二、Java 内存模型

1.概念

为了更好地解决上面提到的系列问题,内存模型被总结提出,我们可以把内存模型理解为在特定操作协议下,对特定的区域或高速缓存进行读写访问的过程抽象

不同架构的物理计算机可以有不一样的内存模型,Java 虚拟机也有自己的内存模型

Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,简称 JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型差异,对个平台定制化地开发程序

更具体一点说,Java 内存模型提出的目标在于,定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节

此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数值对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的

注:如果局部变量是一个 reference 类型,它引用的对象在 Java 堆中可被各个线程共享,但是 reference 本身在 Java 栈的局部变量表中,它是线程私有的

2.Java 内存模型的组成

主内存

Java 内存模型规定了所有变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件的主内存名字一样,两者可以互相类比,但此处仅是虚拟机内存的一部分)

工作内存

每条线程都有自己的工作内存(Working Memory,又称本地内存,可与前面介绍的处理器高速缓存类比),线程的工作内存中保存了该线程需要使用的、在主内存中的共享变量的拷贝

工作内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化

3.JVM 内存操作的并发问题

结合前面介绍的物理机的处理器处理内存的问题,可以类比总结出 JVM 内存操作的问题,下面介绍的 Java 内存模型的执行处理将围绕解决这两个问题展开

工作内存数据一致性

各个线程操作数据时会保存使用到的主内存中的共享变量副本,当多个线程的运算任务都涉及同一个共享变量时,将导致各自的共享变量副本不一致,如果真的发生这种情况,数据同步回主内存以谁的副本数据为准?

Java 内存模型主要通过一系列的数据同步协议、规则来保证数据的一致性

指令重排序优化

Java 中重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段

重排序分为两类:编译器重排序和运行期重排序,分别对应编译时和运行时环境

同样的,指令重排序不是随意重排序,它需要满足以下两个条件:

  • 在单线程环境下不能改变程序运行的结果
    即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象,经过重排序的执行结果要与顺序执行的结果保持一致
  • 存在数据依赖关系的不允许重排序

多线程环境下,如果线程处理逻辑之间存在依赖关系,有可能因为指令重排序导致运行结果与预期不同,下文会展开介绍 Java 内存模型如何解决问题

三、Java 内存间的交互操作

在理解 Java 内存模型的系列协议、特殊规则之前,我们先理解 Java 中内存间的交互操作

1.交互操作流程

为了更好地理解内存的交互操作,以线程通信为例,我们看看具体如何进行线程间值的同步:

线程 1 和线程 2 都有主内存中共享变量 x 的副本,初始时,这 3 个内存中 x 的值都为 0

线程 1 中更新 x 的值为 1 之后同步到线程 2 主要涉及两个步骤:

  • 线程 1 把线程工作内存中更新过的 x 的值刷新到主内存中
  • 线程 2 到主内存中读取线程 1 之前已更新过的 x 变量

从整体上看,这两个步骤是线程 1 在向线程 2 发消息,这个通信过程必须经过主内存

JMM 通过控制主内存与每个线程本地内存之间的交互,来为各个线程提供共享变量的可见性

2.内存交互的基本操作

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了下面 8 个操作来完成

虚拟机实现时必须保证下面介绍的每种操作都是原子的,不可再分的(对于 double 和 long 型的变量来说,load、store、read 和 write 操作在某些平台上允许有例外)

  • lock(锁定)
    作用于主内存的变量,它把一个变量标识为一条线程独占的状态
  • unlock(解锁)
    作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read(读取)
    作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
  • load(载入)
    作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中
  • use(使用)
    作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值得字节码指令时就会执行这个操作
  • assign(赋值)
    作用于工作内存的变量,它把一个执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
  • store(存储)
    作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后 write 操作使用
  • write(写入)
    作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中

为了保证内存间数据的一致性,JMM 中规定这 8 种操作需要满足以下规则

  1. 如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作,但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行
  2. 不允许 read 和 load 、store 和 write 操作之一单独出现
  3. 不允许一个线程丢弃它最近的 assign 操作,即变量在工作内存中改变了之后必须同步到主内存中
  4. 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中
  5. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即对一个变量实施 use 和 store 操作之前,必须先执行过了 load 或 assign 操作
  6. 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。所以 lock 和 unlock 必须成对出现
  7. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
  8. 如果一个变量实现没有被 lock 操作锁定,则不允许对它执行 unlock 操作。也不允许去 unlock 一个被其他线程锁定的变量
  9. 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中,即执行 store 和 write 操作

四、Java 内存间运行规则

1.内存交互基本操作的 3 个特性

在介绍内存交互的具体的 8 种基本操作之前,有必要先介绍一下操作的 3 个特性

Java 内存模型是围绕着在并发过程中如何处理这 3 个特性来建立的,这里先给出定义和基本实现的介绍,后面会逐步展开分析

原子性(Atomicity)

原子性,即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行

即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰

可见性(Visibility)

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值

正如上面 “交互操作流程” 中所说明的一样,JMM 是通过在线程 1 变量工作内存修改后,将新值同步回主内存,线程 2 再从主内存读取同步后的变量值,这是一种依赖主内存作为传递媒介来实现可见性的方式

有序性(Ordering)

有序性规则表现在以下两种场景:

  • 线程内
    从某个线程的角度看方法的执行,指令会按照一种叫 “串行(as-if-serial)” 的方式执行,此种方式已经应用于顺序编程语言
  • 线程间
    这个线程 “观察” 到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。唯一起作用的约束是:对于同步方法、同步块以及 volatile 修饰的字段的操作仍维持相对有序

Java 内存模型的一系列运行规则看起来有点繁琐,但总结起来,是围绕原子性、可见性、有序性特征建立的

归根究底,是为了实现共享变量在多个线程的工作内存中保持数据一致性,保证在多线程并发、会进行指令重排序优化的环境中,程序能如预期运行

2.happens-before 关系

happens-before 是用于描述下两个操作的内存可见性关系的。如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见

happens-before 关系的分析需要分两种情况:

  • 单线程下的 happens-before
    字节码的先后顺序天然包含 happens-before 关系,因为单线程内共享同一份工作内存,不存在数据一致性的问题。在程序控制流路径中靠前的字节码 happens-before 靠后的字节码,靠前的字节码执行完的结果对靠后的字节码可见。然而这并不意味着前者一定在后者之前执行,如果后者不依赖前者的运行结果,那么它们可能会被重排序
  • 多线程下的 happens-before
    多线程由于每个线程有共享变量的副本,如果没有对共享变量做同步处理,线程 1 改变了共享变量 A 的值之后,线程 2 不一定知道线程 1 改变了 A 的值

为了方便程序开发,Java 内存模型实现了下述支持 happens-before 关系的操作:

  • 程序次序规则
    一个线程内,按照代码顺序,书写在前面的操作 happens-before 书写在后面的操作
  • 锁定规则
    一个 unlock 操作 happens-before 后面对同一个锁的 lock 操作
  • volatile 变量规则
    对一个变量的写操作 happens-before 后面对这个变量的读操作
  • 传递规则
    如果操作 A happens-before 操作 B,而操作 B 又 happens-before 操作 C,则可以得出操作 A happens-before 操作 C
  • 线程启动规则
    Thread 对象的 start() 方法 happens-before 此线程的所有操作
  • 线程中断规则
    对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件
  • 线程终结规则
    线程中所有的操作都 happens-before 线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等方式检测到线程已经终止执行
  • 对象终结规则
    一个对象的初始化完成 happens-before 它的 finalize() 方法的开始

3.内存屏障

内存屏障是被插入两个 CPU 指令之间的一种指令,用来禁止处理器指令发生重排序,从而保障有序性

另外,为了达到屏障的效果,它也会使处理器在写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障可见性

常见有 4 种屏障:

  • LoadLoad 屏障
    对于这样的语句:Load1;LoadLoad;Load2;
    在 Load2 及后续读取操作要读取的数据被访问前,保证了 Load1 要读取的数据被读取完毕
  • StoreStore 屏障
    对于这样的语句:Store1;StoreStore;Store2;
    在 Store2 及后续写入操作执行前,保证了 Store1 的写入操作对其他处理器可见
  • LoadStore 屏障
    对于这样的语句:Load1;LoadStore;Store2;
    在 Store 2 及后续写入操作执行前,保证了 Load1 要读取的数据被读取完毕
  • StoreLoad 屏障
    对于这样的语句:Store1;StoreLoad;Load2;
    在 Load2 及后续读取操作要读取的数据被访问前,保证了 Store1 的写入操作对其他处理器可见

四种屏障中,最后一种是开销最大的,需要冲刷写缓冲器和清空无效化队列,在大多数处理器的实现中,这个屏障是个万能屏障,兼具其他三种内存屏障的功能

Java 中对内存屏障的使用在一般的代码中不太容易见到,常见的有 volatile 和 synchronized 关键字修饰的代码块,还可以通过 Unsafe 这个类来使用内存屏障

4.volatile 变量的特殊规则

volatile 翻译为不稳定的;易变的,用 volatile 修饰变量是为了保证变量的可见性

volatile 主要有下面两种语义:

  • 保证可见性
  • 禁止进行指令重排序

保证可见性,保证了不同线程对该变量操作的内存可见性。这里保证可见性不等同于 volatile 变量并发操作的安全性,保证可见性具体一点的解释:

  • 线程对变量进行修改之后,要立刻回写到主内存
  • 线程对变量读取的时候,要从主内存中读,而不是从线程的工作内存中读

但是如果多个线程同时把更新后的变量值同时刷新回主内存,可能导致得到的值不是预期结果

举个例子:
定义 volatile int count = 0;
两个线程同时执行 count++;
每个线程都执行 500 次,但最终结果会小于 1000

原因是每个线程 count++ 需要以下 3 个步骤:

  1. 线程从主内存读取最新的 count 的值
  2. 执行引擎把 count 的值加 1,并赋值给线程的工作内存
  3. 把线程的工作内存中的 count 值保存到主内存

那么就有可能在某一时刻,两个线程在步骤 1 读取到的值都是 100,执行完步骤 2 得到的值都是 101,最后都把 101 保存到主内存

禁止进行指令重排序,具体一点解释,禁止重排序的规则如下:

  • 当程序执行到 volatile 变量的读操作或者写操作时,必须保证在其前面的操作的更改肯定全部已经执行完毕,且结果已经对后面的操作可见;而在其后面的操作肯定还未开始执行
  • 在进行指令优化时,不能把对 volatile 变量访问的语句放在它后面执行,也不能把 volatile 变量后面的语句放到它前面执行

普通的变量仅仅会保证该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证赋值操作的顺序与程序代码中的执行顺序一致

举个例子:(伪代码)

volatile boolean initialized = false;//下面代码在线程 A 中执行
//读取配置信息,当读取完成后将 initialized 设置为 true 以通知其他线程配置可用
doSomethingReadConfig();
initialized = true;//下面代码在线程 B 中执行
//等待 initialized 为 true,代表线程 A 已经把配置信息初始化完成
while(!initialized){sleep();
}
//使线程 A 初始化好的配置信息
doSomethingWithConfig;

上面代码中,如果定义 initialized 变量时没有使用 volatile 修饰,就有可能会由于指令重排序优化,导致线程 A 中最后一句带啊吗 ”initialized = true“ 在 ”doSomethingReadConfig()“ 之前被执行。这样会导致线程 B 中使用配置信息的代码可能会出现错误

volatile 变量实现的原理

具体实现方式是在编译器生成字节码时,在指令序列中增加内存屏障来保证,下面是基于保守策略的 JMM 内存屏障插入策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
    该屏障除了保证屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证 volatile 写操作之前,任何的读写操作都会先于 volatile 被提交
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
    该屏障除了使 volatile 写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使 volatile 变量的写更新对其他线程可见
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
    该屏障除了使 volatile 读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存,使 volatile 变量读取的为最新值
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障
    该屏障除了禁止了 volatile 读操作与其之后的任何写操作进行重排序,还会刷新处理器缓存,使其他线程 volatile 变量的写更新对 volatile 读操作的线程可见

volatile 的使用场景

简而言之,就是 “一次写入,到处读取”,某一线程负责更新变量,其他线程只读取变量,不更新变量,并根据变量的新值执行相应逻辑。例如状态标志位更新,观察者模型变量值发布

5.final 变量的特殊规则

我们知道,final 成员变量必须在声明的时候初始化或者在构造器中初始化,否则就会报编译错误

final 关键字的可见性是指:
被 final 修饰的字段在声明时或者在构造器中,一旦初始化完成,那么在其他线程无须同步就能正确看见 final 字段的值。这时因为一旦初始化完成,final 变量的值立刻会写到主内存

6.synchronized 变量的特殊规则

通过 synchronized 关键字包住的代码区域,会读数据的读写进行控制:

  • 读操作
    当线程进入到该区域读取变量信息时,对数据的读取不能从工作内存读取,只能从主内存中读取,保证读到的是最新的值
  • 写操作
    对于在同步区内执行的写入操作,在离开同步区时就会将当前线程内的数据刷新到主内存中,保证更新的数据对其他线程的可见性

7.long 和 double 变量的特殊规则

Java 内存模型要求八种基本操作具有原子性,但是对于 64 位的数据类型(long 和 double),在模型中特别定义相对宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作分为 2 次 32 位的操作来进行。也就是说虚拟机可选择不保证 64 位数据类型的 load、store、read、和 write 这四个操作的原子性

因为对于 32 位的操作系统来说,单次操作能处理的最长长度为 32 位,而 long 和 double 占 8 字节(64 位),这样的话对它们的读写都需要两条指令才能完成,如果虚拟机没有选择保证它们的原子性,就需要用 volatile 来修饰以保证原子性

当然,如果是 64 位的操作系统,那么 long 和 double 就不存在这个问题,对它们的处理就是原子性的


参考链接

京东架构师100分钟带你重新认识Java内存模型!让你面试无忧!
Java内存模型原理,你真的理解吗?
Java 中 long 和 double 的原子性?

【JVM】第四章 Java内存模型相关推荐

  1. JVM之Java内存模型(基于《深入理解Java虚拟机》之第12章Java内存模型与线程)(上)

    多任务处理为什么在OS中几乎是一项必备的功能? sadsa sadsa①.计算机的运算能力强大了,但其运算速度与它的存储和 通信子系统的速度 差距太大了,不匹配,大量的时间都花费在磁盘I/O.网络通信 ...

  2. Java并发编程 - 第三章 Java内存模型

    前言: Java 线程之间的通信对程序员完全透明,内存可见性问题很容易困扰 Java 程序员,本章将揭开 Java 内存模型神秘的面纱. 一.Java 内存模型的基础 1.1 并发编程模型的两个关键问 ...

  3. java并发编程实战:第十六章----Java内存模型

    一.什么是内存模型,为什么要使用它 如果缺少同步,那么将会有许多因素使得线程无法立即甚至永远看到一个线程的操作结果 编译器把变量保存在本地寄存器而不是内存中 编译器中生成的指令顺序,可以与源代码中的顺 ...

  4. 并发之初章Java内存模型

    >>>>>>博客地址<<<<<< >>>>>>首发博客<<<<< ...

  5. 深入理解Java虚拟机——第十二章——Java内存模型与线程

    硬件效率与一致性 处理器需要与内存交互,但处理器运算速度与对内存的I/O操作速度相差几个数量级,因此现代操作系统不得不加入尽可能接近处理器运算速度的高速缓存来作为内存与处理器之前的缓冲.这样处理器就不 ...

  6. 《Java并发编程的艺术》第3章 Java内存模型

    3.1.1 Java并发模型的两个关键问题 并发编程中,两个关键问题:线程通信以及线程同步 这里的线程是并发执行的活动实体. 通信是指线程以何种机制交换消息.机制有两种:共享内存(写读内存中的状态隐式 ...

  7. JVM笔记1:Java内存模型及内存溢出

    灰色:所有线程间共享 白色:线程间隔离 程序计数器:当前线程所执行的字节码的行号指示器,字节码解释器通过改变该计数器的值来选取下一条需要执行的字节码指令. 1,一块很小的内存空间 2,每条线程都需要一 ...

  8. java线程的优先级是数字越大优先级越高_《深入理解Java虚拟机》5分钟速成:12章(Java内存模型与线程)...

    第12章 Java内存模型与线程 前言: 1.物理机如何处理并发问题? 2.什么是Java内存模型? 3.原子性.可见性.有序性的具体含义和应用实现? 4.volatile 关键字特性? 5.基于vo ...

  9. jvm(12)-java内存模型与线程

    [0]README 0.1)本文部分文字描述转自"深入理解jvm",旨在学习"java内存模型与线程" 的基础知识: [1]概述 1)并发处理的广泛应用是使得 ...

最新文章

  1. mysql 上亿记录_一入职!就遇到上亿(MySQL)大表的优化....
  2. zenmap扫描出来linux,如何在Linux上使用NMAP安全扫描器
  3. [Azure] 使用 Visual Studio 2013 管理中国版 Azure 订阅
  4. 20 年“码龄”的老程序员如何看编程发展?
  5. java基础知识---IO常用基础操作(二)
  6. Android系统中的广播(Broadcast)机制简要介绍和学习计划 .
  7. android删除进度条,android – 在RecylerView上设置进度条,并在加载数据后删除
  8. 01-artDialog4.1.7常用整理
  9. 连续亏损的哈啰,转型多元化困难重重
  10. 3dmax2022新功能特性-3ds max2022中文版安装包安装教程
  11. 解决Eclipse保存web.xml卡的问题
  12. python format 用法详解
  13. 计算机cccc比赛,2019第四届中国高校计算机大赛—团体程序设计天梯赛 CCCC 总结...
  14. Cura参数设置-避免支撑拆除带来的困难
  15. 在MySQL中实现交叉表查询1(静态交叉表)
  16. 炉石android更新日志,炉石传说新版本一览_炉石传说更新内容
  17. 【设计模式】的C语言的设计模式及其应用
  18. 100句勉励自己的人生格言
  19. maven和springboot
  20. 从零开始的VIO——Eigen和Sophus的安装配置

热门文章

  1. php重定向error,将nginx错误重定向到php
  2. 自动化测试---页面截图
  3. 数据属性和访问器属性
  4. git完全cli指南之详细思维导图整理分享
  5. Android数据储存之SharedPreferences总结
  6. linux内核剪裁 4412,itop4412开发板-Linux内核的编译
  7. eclipse maven打包_我的Java Web之路47 - 使用Maven改造租房网工程
  8. php和c语言的字符数组中,字符数组和字符串的区别,C语言字符数组和字符串区别详解...
  9. SkyEye仿真平台下的操作系统实验- 准备篇(一)
  10. (111)FPGA面试题-介绍Verilog 块语句fork-join执行过程