开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

【漫画】JAVA并发编程三大Bug源头(可见性、原子性、有序

发表于 2020-05-07

原创声明:本文转载自公众号【胖滚猪学编程】​

某日,胖滚猪写的代码导致了一个生产bug,奋战到凌晨三点依旧没有解决问题。胖滚熊一看,只用了一个volatile就解决了。并告知胖滚猪,这是并发编程导致的坑。这让胖滚猪坚定了要学好并发编程的决心。。于是,开始了我们并发编程的第一课。

序幕

con2

BUG源头之一:可见性

刚刚我们说到,CPU缓存可以提高程序性能,但缓存也是造成BUG源头之一,因为缓存可以导致可见性问题。我们先来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码private static int count = 0;
public static void main(String[] args) throws Exception {
Thread th1 = new Thread(() -> {
count = 10;
});
Thread th2 = new Thread(() -> {
//极小概率会出现等于0的情况
System.out.println("count=" + count);
});
th1.start();
th2.start();
}

按理来说,应该正确返回10,但结果却有可能是0。

一个线程对变量的改变另一个线程没有get到,这就是可见性导致的bug。一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

那么在谈论可见性问题之前,你必须了解下JAVA的内存模型,我绘制了一张图来描述:

JAVA_

主内存(Main Memory)

主内存可以简单理解为计算机当中的内存,但又不完全等同。主内存被所有的线程所共享,对于一个共享变量(比如静态变量,或是堆内存中的实例)来说,主内存当中存储了它的“本尊”。

工作内存(Working Memory)

工作内存可以简单理解为计算机当中的CPU高速缓存,但准确的说它是涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。每一个线程拥有自己的工作内存,对于一个共享变量来说,工作内存当中存储了它的“副本”。

线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
线程之间无法直接访问对方的工作内存中的变量,线程间变量的传递均需要通过主内存来完成

现在再回到刚刚的问题,为什么那段代码会导致可见性问题呢,根据内存模型来分析,我相信你会有答案了。当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存
image

由于线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,那么对于共享变量V,它们首先是在自己的工作内存,之后再同步到主内存。可是并不会及时的刷到主存中,而是会有一定时间差。很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了 。

con3_1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
复制代码private volatile long count = 0;
​
private void add10K() {
int idx = 0;
while (idx++ < 10000) {
count++;
}
}
​
public static void main(String[] args) throws InterruptedException {
TestVolatile2 test = new TestVolatile2();
// 创建两个线程,执行 add() 操作
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束
th1.join();
th2.join();
// 介于1w-2w,即使加了volatile也达不到2w
System.out.println(test.count);
}
​

con3_2

原创声明:本文转载自公众号【胖滚猪学编程】​

原子性问题

一个不可分割的操作叫做原子性操作,它不会被线程调度机制打断的,这种操作一旦开始,就一直运行到结束,中间不会有任何线程切换。注意线程切换是重点!

我们都知道CPU资源的分配都是以线程为单位的,并且是分时调用,操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。而任务的切换大多数是在时间片段结束以后,

_

那么线程切换为什么会带来bug呢?因为操作系统做任务切换,可以发生在任何一条CPU 指令执行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高级语言里的一条语句。比如count++,在java里就是一句话,但高级语言里一条语句往往需要多条 CPU 指令完成。其实count++包含了三个CPU指令!

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

小技巧:可以写一个简单的count++程序,依次执行javac TestCount.java,javap -c -s TestCount.class得到汇编指令,验证下count++确实是分成了多条指令的。

volatile虽然能保证执行完及时把变量刷到主内存中,但对于count++这种非原子性、多指令的情况,由于线程切换,线程A刚把count=0加载到工作内存,线程B就可以开始工作了,这样就会导致线程A和B执行完的结果都是1,都写到主内存中,主内存的值还是1不是2,下面这张图形象表示了该历程:

_

image

原创声明:本文转载自公众号【胖滚猪学编程】​

有序性问题

JAVA为了优化性能,允许编译器和处理器对指令进行重排序,即有时候会改变程序中语句的先后顺序:

例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”只是在这个程序中不影响程序的最终结果。

有序性指的是程序按照代码的先后顺序执行。但是不要望文生义,这里的顺序不是按照代码位置的依次顺序执行指令,指的是最终结果在我们看起来就像是有序的。

重排序的过程不会影响单线程程序的执行,却会影响到多线程并发执行的正确性。有时候编译器及解释器的优化可能导致意想不到的 Bug。比如非常经典的双重检查创建单例对象。

1
2
3
4
5
6
7
8
9
10
11
12
复制代码public class Singleton { 
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

你可能会觉得这个程序天衣无缝,我两次判断是否为空,还用了synchronized,刚刚也说了,synchronized 是独占锁/排他锁。按照常理来说,应该是这么一个逻辑:
线程A和B同时进来,判断instance == null,线程A先获取了锁,B等待,然后线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时加锁会成功,然后线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

但多线程往往要有非常理性的思维,我们先分析一下 instance = new Singleton()这句话,根据刚刚原子性说到的,一句高级语言在cpu层面其实是多条指令,这也不例外,我们也很熟悉new了,它会分为以下几条指令:
1、分配一块内存 M;
2、在内存 M 上初始化 Singleton 对象;
3、然后 M 的地址赋值给 instance 变量。

如果真按照上述三条指令执行是没问题的,但经过编译优化后的执行路径却是这样的:
1、分配一块内存 M;
2、将 M 的地址赋值给 instance 变量;
3、最后在内存 M 上初始化 Singleton 对象

假如当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;而此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常,如图所示:

_

con4

总结

并发程序是一把双刃剑,一方面大幅度提升了程序性能,另一方面带来了很多隐藏的无形的难以发现的bug。我们首先要知道并发程序的问题在哪里,只有确定了“靶子”,才有可能把问题解决,毕竟所有的解决方案都是针对问题的。并发程序经常出现的诡异问题看上去非常无厘头,但是只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。
总结一句话:可见性是缓存导致的,而线程切换会带来的原子性问题,编译优化会带来有序性问题。至于怎么解决呢!欲知后事如何,且听下回分解。

原创声明:本文转载自公众号【胖滚猪学编程】​

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

高并发环境下如何优化Tomcat性能?看完你就懂了! 写在前

发表于 2020-05-07

写在前面

Tomcat作为最常用的Java Web服务器,随着并发量越来越高,Tomcat的性能会急剧下降,那有没有什么方法来优化Tomcat在高并发环境下的性能呢?

Tomcat运行模式

Tomcat的运行模式有3种。

1.bio模式

默认的模式,性能非常低下,没有经过任何优化处理和支持。

2.nio模式

利用java的异步io护理技术,noblocking IO技术。要想运行在该模式下,则直接修改server.xml里的Connector节点,修改protocol为如下配置。

1
复制代码protocol="org.apache.coyote.http11.Http11NioProtocol"

重启Tomcat后,就可以生效。

3.apr模式

安装起来最困难,但是从操作系统级别来解决异步的IO问题,大幅度的提高性能。此种模式下,必须要安装apr和native,直接启动就支持apr。如nio修改模式,修改protocol为org.apache.coyote.http11.Http11AprProtocol,如下所示。

1
复制代码protocol="org.apache.coyote.http11.Http11AprProtocol"

Tomcat并发优化

安装APR

1
复制代码[root@binghe ~]# yum -y install apr apr-devel openssl-devel[root@binghe ~]# tar zxvf tomcat-native.tar.gz[root@binghe ~]# cd tomcat-native-1.1.24-src/jni/native[root@binghe native]# ./configure --with-apr=/usr/bin/apr-1-config --with-ssl=/usr/include/openssl/[root@binghe native]# make && make install

安装完成之后 会出现如下提示信息

1
复制代码Libraries have been installed in:/usr/local/apr/lib

安装成功后还需要对tomcat设置环境变量,方法是在catalina.sh文件中增加1行:

在这段代码下面添加:

高并发环境下如何优化Tomcat性能?看完你就懂了!修改server.xml的配置,如下所示。

1
复制代码protocol=”org.apache.coyote.http11.Http11AprProtocol”

启动tomcat之后,查看日志,如下所示。

1
复制代码more TOMCAT_HOME/logs/catalina.out2020-04-17 22:34:56 org.apache.catalina.core.AprLifecycleListener initINFO: Loaded APR based Apache Tomcat Native library 1.1.31 using APR version 1.3.9.2020-04-17 22:34:56 org.apache.catalina.core.AprLifecycleListener initINFO: APR capabilities: IPv6 [true], sendfile [true], accept filters [false], random [true].2020-04-17 22:34:56 org.apache.catalina.core.AprLifecycleListener initializeSSLINFO: OpenSSL successfully initialized (OpenSSL 1.0.1e 11 Feb 2013)2020-04-17 22:34:58 AM org.apache.coyote.AbstractProtocol initINFO: Initializing ProtocolHandler [“http-apr-8080”]2020-04-17 22:34:58 AM org.apache.coyote.AbstractProtocol initINFO: Initializing ProtocolHandler [“ajp-apr-8009”]2020-04-17 22:34:58 AM org.apache.catalina.startup.Catalina loadINFO: Initialization processed in 1125 ms

Tomcat优化

1.JVM 调优

在TOMCAT_HOME/bin/catalina.sh 增加如下语句,具体数值视情况而定。添加到上面CATALINA_OPTS的后面即可,如下所示。

1
复制代码JAVA_OPTS=-Xms512m -Xmx1024m -XX:PermSize=512M -XX:MaxNewSize=1024m -XX:MaxPermSize=1024m

参数详解

  • -Xms:JVM初始化堆内存大小。
  • -Xmx:JVM堆的最大内存。
  • -Xss:线程栈大小。
  • -XX:PermSize:JVM非堆区初始内存分配大小。
  • -XX:MaxPermSize:JVM非堆区最大内存。

建议和注意事项:

-Xms和-Xmx选项设置为相同堆内存分配,以避免在每次GC 后调整堆的大小,堆内存建议占内存的60%~80%;非堆内存是不可回收内存,大小视项目而定;线程栈大小推荐256k。

32G内存配置如下:

1
复制代码JAVA_OPTS=-Xms20480m -Xmx20480m -Xss1024K -XX:PermSize=512m -XX:MaxPermSize=2048m

2.关闭DNS反向查询

在<Connector port=”8080″ 中加入如下参数。

1
复制代码enableLookups=”false”

3.优化tomcat参数

在server.xml文件中进行如下配置。

1
复制代码<Connector port=”8080″protocol=”org.apache.coyote.http11.Http11AprProtocol”connectionTimeout=”20000″ //链接超时时长redirectPort=”8443″maxThreads=”500″//设定处理客户请求的线程的最大数目,决定了服务器可以同时响应客户请求的数,默认200minSpareThreads=”20″//初始化线程数,最小空闲线程数,默认为10acceptCount=”1000″ //当所有可以使用的处理请求的线程数都被使用时,可以被放到处理队列中请求数,请求数超过这个数的请求将不予处理,默认100enableLookups=”false”URIEncoding=”UTF-8″ />

写在最后

最后,附上并发编程需要掌握的核心技能知识图,祝大家在学习并发编程时,少走弯路。

高并发环境下如何优化Tomcat性能?看完你就懂了!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官没想到一个Volatile,我都能跟他扯半小时

发表于 2020-05-07

点赞再看,养成习惯,微信搜索【三太子敖丙】关注这个互联网苟且偷生的工具人。

本文 GitHub github.com/JavaFamily 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

Volatile可能是面试里面必问的一个话题吧,对他的认知很多朋友也仅限于会用阶段,今天我们换个角度去看看。

先来跟着丙丙来看一段demo的代码


你会发现,永远都不会输出有点东西这一段代码,按道理线程改了flag变量,主线程也能访问到的呀?

为会出现这个情况呢?那我们就需要聊一下另外一个东西了。

JMM(JavaMemoryModel)

JMM:Java内存模型,是java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别(注意这个跟JVM完全不是一个东西,只有还有小伙伴搞错的)。

那正式聊之前,丙丙先大概科普一下现代计算机的内存模型吧。

现代计算机的内存模型

其实早期计算机中cpu和内存的速度是差不多的,但在现代计算机中,cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。

将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(CacheCoherence)。

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。


然后我们可以聊一下JMM了。

JMM

Java内存模型(JavaMemoryModel)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节。

JMM有以下规定:

所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。

每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。

线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。

不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

本地内存和主内存的关系:


正是因为这样的机制,才导致了可见性问题的存在,那我们就讨论下可见性的解决方案。

可见性的解决方案

加锁

为啥加锁可以解决可见性问题呢?

因为某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。

而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。

Volatile修饰共享变量


开头的代码优化完之后应该是这样的:

Volatile做了啥?

每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果他操作了数据并且写会了,他其他已经读取的线程的变量副本就会失效了,需要都数据进行操作又要再次去主内存中读取了。

volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。

是不是看着加一个关键字很简单,但实际上他在背后含辛茹苦默默付出了不少,我从计算机层面的缓存一致性协议解释一下这些名词的意义。


之前我们说过当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,举例说明变量在多个CPU之间的共享。

如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?

为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。

聊一下Intel的MESI吧

MESI(缓存一致性协议)

当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

至于是怎么发现数据是否失效呢?

嗅探

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

嗅探的缺点不知道大家发现了没有?

总线风暴

由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。

所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。

我们再来聊一下指令重排序的问题

禁止指令重排序

什么是重排序?

为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。

重排序的类型有哪些呢?源码到最终执行会经过哪些重排序呢?


一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标,而进行奋斗:在不改变程序执行结果的前提下,尽可能提高执行效率。

JMM对底层尽量减少约束,使其能够发挥自身优势。

因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。

一般重排序可以分为如下三种:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

这里还得提一个概念,as-if-serial。

as-if-serial

不管怎么重排序,单线程下的执行结果不能被改变。

编译器、runtime和处理器都必须遵守as-if-serial语义。

那Volatile是怎么保证不会被执行重排序的呢?

内存屏障

java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。

为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:


需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。

写

读


上面的我提过重排序原则,为了提高处理速度,JVM会对代码进行编译优化,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患:如指令重排序导致的多个线程操作之间的不可见性。

如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。

从JDK5开始,提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。

happens-before

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。

如果现在我的变了falg变成了false,那么后面的那个操作,一定要知道我变了。

聊了这么多,我们要知道Volatile是没办法保证原子性的,一定要保证原子性,可以使用其他方法。

无法保证原子性

就是一次操作,要么完全成功,要么完全失败。

假设现在有N个线程对同一个变量进行累加也是没办法保证结果是对的,因为读写这个过程并不是原子性的。

要解决也简单,要么用原子类,比如AtomicInteger,要么加锁(记得关注Atomic的底层)。

应用


单例有8种写法,我说一下里面比较特殊的一种,涉及Volatile的。

大家可能好奇为啥要双重检查?如果不用Volatile会怎么样?

我先讲一下禁止指令重排序的好处。

对象实际上创建对象要进过如下几个步骤:

  • 分配内存空间。
  • 调用构造器,初始化实例。
  • 返回地址给引用

上面我不是说了嘛,是可能发生指令重排序的,那有可能构造函数在对象初始化完成前就赋值完成了,在内存里面开辟了一片存储区域后直接返回内存的引用,这个时候还没真正的初始化完对象。

但是别的线程去判断instance!=null,直接拿去用了,其实这个对象是个半成品,那就有空指针异常了。

可见性怎么保证的?

因为可见性,线程A在自己的内存初始化了对象,还没来得及写回主内存,B线程也这么做了,那就创建了多个对象,不是真正意义上的单例了。

上面提到了volatile与synchronized,那我聊一下他们的区别。

volatile与synchronized的区别

volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。

volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制。
volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。

volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。


总结
–

  1. volatile修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,比如booleanflag;或者作为触发器,实现轻量级同步。
  2. volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁_上,所以说它是低成本的。
  3. volatile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序。
  4. volatile提供了可见性,任何一个线程对其的修改将立马对其他线程可见,volatile属性不会被线程缓存,始终从主
    存中读取。
  5. volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。
  6. volatile可以使得long和double的赋值是原子的。
  7. volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。

注:以上所有的内容如果能全部掌握我想Volatile在面试官那是很加分了,但是我还没讲到很多关于计算机内存那一块的底层,那大家就需要后面去补课了,如果等得及,也可以等到我写计算机基础章节。

絮叨

img

img

因为更新文章和视频,丙丙已经半年多的周末没休息了,都是在公司那个工位冲冲冲,一直想找时间出去玩,想着年假一天没用,就请了两天出去玩一下。

这样五一就可以早点回来,准备恢复视频的更新,你在看的时候呢,敖丙应该在出游的列车上了,是的我就背了这个包,到写完的时候,我还没确定去哪里,提前祝大家节日愉快。

我是敖丙,一个在互联网苟且偷生的工具人。

你知道的越多,你不知道的越多,人才们的 【三连】 就是丙丙创作的最大动力,我们下期见!

注:如果本篇博客有任何错误和建议,欢迎人才们留言,你快说句话啊!


文章持续更新,可以微信搜索「 三太子敖丙 」第一时间阅读,回复【资料】【面试】【简历】有我准备的一线大厂面试资料和简历模板,本文 GitHub github.com/JavaFamily 已经收录,有大厂面试完整考点,欢迎Star。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

如何轻松学习 Kubernetes? 什么是 Kuberne

发表于 2020-05-06

1.png

作者 | 声东 阿里巴巴技术专家

<关注阿里巴巴云原生公众号,回复 排查 即可下载电子书>

导读:《深入浅出 Kubernetes》一书共汇集 12 篇技术文章,帮助你一次搞懂 6 个核心原理,吃透基础理论,一次学会 6 个典型问题的华丽操作!

什么是 Kubernetes?

我们来看一下什么是 Kubernetes。这部分内容我会从四个角度来跟大家分享一下我的看法。

  1. 未来什么样

2.png

这是一张未来大部分公司后端 IT 基础设施的架构图。简单来说,以后所有公司的 IT 基础设施都会部署在云上。用户会基于 Kubernetes 把底层云资源分割成具体的集群单元,给不同的业务使用。而随着业务微服务化的深入,服务网格这样的服务治理逻辑会变得跟下边两层一样,成为基础设施的范畴。

目前,阿里基本上所有的业务都跑在云上。而其中大约有一半的业务已经迁移到了自己定制 Kubernetes 集群上。另外据我了解,阿里计划今年完成 100% 的基于 Kubernetes 集群的业务部署。

而服务网格这块,在阿里的一些部门,像蚂蚁金服,其实已经有线上业务在用了。大家可以通过蚂蚁一些同学的分享来了解他们的实践过程。

虽然这张图里的观点可能有点绝对,但是目前这个趋势是非常明显的。所以未来几年, Kubernetes 肯定会变成像 Linux 一样的,作为集群的操作系统无处不在。

  1. Kubernetes 与操作系统

3.png

这是一张传统的操作系统和 Kubernetes 的比较图。大家都知道,作为一个传统的操作系统,像 Linux 或者 Windows,它们扮演的角色,就是底层硬件的 一个抽象层。它们向下管理计算机的硬件,像内存或 CPU,然后把底层硬件抽象成一些易用的接口,用这些接口,向上对应用层提供支持。

而 Kubernetes 呢,我们也可以把它理解为一个操作系统。这个操作系统说白了也是一个抽象层,它向下管理的硬件,不是内存或者 CPU 这种硬件,而是多台计算机组成的集群,这些计算机本身就是普通的单机系统,有自己的操作系统和硬件。Kubernetes 把这些计算机当成一个资源池来 统一管理,向上对应用提供支撑。

这里的应用比较特别,就是这些应用都是容器化的应用。如果对容器不太了解的同学,可以简单把这些应用,理解为一个应用安装文件。安装文件打包了所有的依赖库,比如 libc 这些。这些应用不会依赖底层操作系统的库文件来运行。

  1. Kubernetes 与 Google 运维解密

4.png

上图中,左边是一个 Kubernetes 集群,右边是一本非常有名的书,就是 Google 运维解密这本书。相信很多人都看过这本书,而且有很多公司目前也在实践这本书里的方法。包括故障管理,运维排班等。

Kubernetes 和这本书的关系,我们可以把他们比作剑法和气功的关系。不知道这里有多少人看过笑傲江湖。笑傲江湖里的华山派分两个派别,气宗和剑宗。气宗注重气功修炼,而剑宗更强调剑法的精妙。实际上气宗和剑宗的分家,是因为华山派两个弟子偷学一本葵花宝典,两个人各记了一部分,最终因为观点分歧分成了两派。

Kubernetes 实际上源自 Google 的集群自动化管理和调度系统 Borg,也就是这本书里讲的运维方法所针对的对象。Borg 系统和书里讲的各种运维方法可以看做是一件事情的两个方面。如果一个公司只去学习他们的运维方法,比如开了 SRE 的职位,而不懂这套方法所管理的系统的话,那其实就是学习葵花宝典,但是只学了一部分。

Borg 因为是 Google 内部的系统,所以我们一般人是看不到的,而 Kubernetes 基本上继承了 Borg 在集群自动化管理方面非常核心的一些理念。所以如果大家看了这本书,觉得很厉害,或者在实践这本书里的方法,那大家一定要深入理解下 Kubernetes。

  1. 技术演进史

5.png

早期的时候,我们做一个网站后端,可能只需要把所有的模块放在一个可执行文件里,就像上图一样,我们有 UI、数据和业务三个模块,这三个模块被编译成一个可执行文件,跑在一台服务器上。

但是随着业务量的大幅增长,我们没有办法,通过升级服务器配置的方式来扩容。这时候我们就必须去做微服务化了。

微服务化会把单体应用拆分成低耦合的小应用。这些应用各自负责一块业务,然后每个应用的实例独占一台服务器,它们之间通过网络互相调用。

这里最关键的是,我们可以通过增加实例个数,来对小应用做横向扩容。这就解决了单台服务器无法扩容的问题。

微服务之后会出现一个问题,就是一个实例占用一台服务器的问题。这种部署方式,资源的浪费其实是比较严重的。这时我们自然会想到,把这些实例混部到底层服务器上。

但是混部会引入两个新问题,一个是依赖库兼容性问题。这些应用依赖的库文件版本可能完全不一样,安装到一个操作系统里,必然会出问题。另一个问题就是应用调度和集群资源管理的问题。

比如一个新的应用被创建出来,我们需要考虑这个应用被调度到哪台服务器,调度上去之后资源够不够用这些问题。

这里的依赖库兼容性问题,是靠容器化来解决的,也就是每个应用自带依赖库,只跟其他应用共享内核。而调度和资源管理就是 Kubernetes 所解决的问题。

顺便提一句,我们可能会因为,集群里混部的应用太多,这些应用关系错综复杂,而没有办法去排查一些像请求响应慢这样的问题。所以类似服务网格这类服务治理的技术,肯定会成为下一个趋势。

怎么学习 Kubernetes?

  1. Kubernetes 学习难点

6.png

总体来说,Kubernetes 之所以门槛比较高,比较难学习,一个是因为它的技术栈非常深,包括了内核,虚拟化,容器,软件定义网络 SDN,存储,安全,甚至可信计算等,绝对可以称得上全栈技术。

同时 Kubernetes 在云环境的实现,肯定会牵扯到非常多的云产品,比如在阿里云上,我们的 Kubernetes 集群用到了 ECS 云服务器,VPC 虚拟网络,负载均衡,安全组,日志服务,云监控,中间件产品像 ahas 和 arms,服务网格,弹性伸缩等等大量云产品。

最后,因为 Kubernetes 是一个通用的计算平台,所以它会被用到各种业务场景中去,比如数据库。据我所知,像我们的 PolarDB Box 一体机就是计划基于 Kubernetes 搭建。另外还有边缘计算,机器学习,流计算等等。

  1. 了解、动手、思考

7.png

基于我个人的经验,学习 Kubernetes,我们需要从了解、动手、以及思考三个方面去把握。

了解其实很重要,特别是了解技术的演进史,以及技术的全景图。

我们需要知道各种技术的演进历史,比如容器技术是怎么从 chroot 这个命令发展而来的,以及技术演进背后要解决的问题是什么,只有知道技术的演进史和发展的动力,我们才能对未来技术方向有自己的判断。

同时我们需要了解技术全景,对 Kubernetes 来说,我们需要了解整个云原生技术栈,包括容器,CICD,微服务、服务网格这些,知道 Kubernetes 在整个技术栈里所处的位置。

除了这些基本的背景知识以外,学习 Kubernetes 技术,动手实践是非常关键的。

从我和大量工程师一起解决问题的经验来说,很多人其实是不会去深入研究技术细节的。我们经常开玩笑说工程师有两种,一种是 search engineer,就是搜索工程师,一种是 research engineer,就是研究工程师。很多工程师遇到问题,google 一把,如果搜不到答案,就直接开工单了。这样是很难深入理解一个技术的。

最后就是怎么去思考,怎么去总结了。我个人的经验是,我们需要在理解技术细节之后,不断的问自己,细节的背后,有没有什么更本质的东西。也就是我们要把复杂的细节看简单,然后找出普通的模式出来。

下边我用两个例子来具体解释一下上边的方法。

  1. 用冰箱来理解集群控制器

8.png

第一个例子是关于集群控制器的。我们在学习 Kubernetes 的时候会听到几个概念,像声明式 API,Operator,面向终态设计等。这些概念本质上 都是在讲一件事情,就是控制器模式。

我们怎么来理解 Kubernetes 的控制器呢?上面这张图是一个经典的 Kubernetes 架构图,这张图里有集群管控节点和工作节点,管控节点上有中心数据库,API Server,调度器及一些控制器。

中心数据库是集群的核心存储系统,API Server 是集群的管控入口,调度器负责把应用调度到资源充沛的节点上。而控制器是我们这里要说的重点。控制器的作用,我们用一句话概括,就是“让梦想照进现实”。从这个意义上来讲,我自己也经常扮演控制器的角色,我女儿如果说,爸爸我要吃冰激凌,那我女儿就是集群的用户,我就是负责把她这个愿望实现的人,就是控制器。

除了管控节点以外,Kubernetes 集群有很多工作节点,这些节点都部署了 Kubelet 和 Proxy 这两个代理。Kubelet 负责管理工作节点,包括应用在节点上启动和停止之类的工作。Proxy 负责把服务的定义落实成具体的 iptables 或者 ipvs 规则。这里服务的概念,其实简单来说,就是利用 iptables 或者 ipvs 来实现负载均衡。

如果我们从控制器的角度来看第一张图的话,我们就会得到第二张图。也就是说,集群实际上就包括一个数据库,一个集群入口,以及很多个控制器。这些组件,包括调度器,Kubelet 以及 Proxy,实际上都是不断的去观察集群里各种资源的定义,然后把这些定义落实成具体的配置,比如容器启动或 iptables 配置。

从控制器的角度观察 Kubernetes 的时候,我们其实得到了 Kubernetes 最根本的一个原理了。就是控制器模式。

其实控制器模式在我们生活中无处不在的,这里我拿冰箱做个例子。我们在控制冰箱的时候,并不会直接去控制冰箱里的制冷系统或者照明系统。我们打开冰箱的时候,里边的灯会打开,我们在设置了想要的温度之后,就算我们不在家,制冷系统也会一直保持这个温度。这背后就是因为有控制器模式在起作用。

  1. 为什么删除不掉命名空间

9.png

第二个例子,我们来看一个真实问题的排查过程。这个问题是一个命名空间不能被删除的问题。问题稍微有点复杂,我们一步一步来看。

命名空间是 Kubernetes 集群的 一个收纳盒机制,就像这里的第一张图片一样。这个盒子就是命名空间,它里边收纳了橡皮和铅笔。

命名空间可以被创建或者删除。我们经常会遇到不能删除命名空间的问题。遇到这个问题,我们如果完全不知道怎么排查。第一步我们可能会想到,研究一下 API Server 是怎么处理这个删除操作的,因为 API Server 就是集群的 管理入口。

API Server 本身是一个应用,我们可以通过提升这个应用的日志级别,来深入理解它的操作流程。在这个问题里,我们会发现,API Server 收到删除命令,但是就没有其他信息了。

这里我们需要稍微理解下命名空间的删除过程,用户在删除命名空间的时候,其实命名空间并不会被直接删除掉,而会被改成“删除中”的状态。这个时候命名空间控制器就会看到这个状态。

为了理解命名空间控制器的行为,我们同样可以把控制器的日志级别提高来查看详细的日志。这个时候呢,我们会发现,控制器正在尝试去获取所有的 API 分组。

到这里我们需要去理解两个事情。一个是为什么删除命名空间,控制器会去获取 API 分组。第二个是 API 分组到底是什么。

我们先看第二个问题,API 分组到底是什么。简单来说,API 分组就是集群 API 的分类机制,比如网络相关的 API 就在 networking 这个组里。而通过网络 API 分组创建出来的资源就属于这个组。

那为什么命名空间控制器会去获取 API 分组呢?是因为在删除命名空间的时候,控制器需要删除命名空间里的所有资源。这个操作不像我们删除文件夹一样,会把里边的文件都一起删掉。

命名空间收纳了资源,实际上是这些资源用类似索引的机制,指向了这个命名空间。集群只有遍历所有的 API 分组,找出指向这个命名空间的所有资源,才能逐个把它们删除掉。

而遍历 API 组这个操作呢,会使得集群的 API Server 和它的扩展进行通信。这是因为 API Server 的扩展,也可以实现一部分 API 分组。所以想知道被删除的命名空间里是不是有包括这个扩展定义的资源,API Server 就必须和扩展通信。

到这一步之后,问题实际上变成 API Server 和他的扩展之间通信的问题。也就是删除资源的问题就变成了网络问题。

阿里云的 Kubernetes 集群,是在 VPC 网络,也就是虚拟局域网上创建的。默认情况下, VPC 的只认识 VPC 网段的地址,而集群里边的容器,一般会使用和 VPC 不同的网段。比如 VPC 使用 172 网段,那容器可能就使用 192 网段。

我们通过在 VPC 的路由表里,增加容器网段的路由项,可以让容器使用 VPC 网络进行通信。

在右下角这张图,我们有两个集群节点,他们的地址是 172 网段,那我们给路由表里增加 192 网段的路由项,就可以让 VPC 把发给容器的数据转发到正确的节点上,再由节点发给具体的容器。

而这里的路由项,是在节点加入集群的时候,由路由控制器来添加的。路由控制器在发现有新节点加入集群之后,会立刻做出反应,给路由表里增加一条路由项。

添加路由项这个操作,其实是对 VPC 的一次操作。这个操作是需要使用一定的授权的,这是因为这个操作跟线下一台机器访问云上资源是差不多的,肯定需要授权。

这里路由控制器使用的授权,是以 RAM 角色的方式绑定到路由控制器所在的集群节点上的。而这个 RAM 角色,正常会有一系列的授权规则。

最后,我们通过检查,发现用户修改了授权规则,所以导致了这个问题。

<关注阿里巴巴云原生公众号,回复 排查 即可下载电子书>

课程推荐

为了更多开发者能够享受到 Serverless 带来的红利,这一次,我们集结了 10+ 位阿里巴巴 Serverless 领域技术专家,打造出最适合开发者入门的 Serverless 公开课,让你即学即用,轻松拥抱云计算的新范式——Serverless。

点击即可免费观看课程:developer.aliyun.com/learning/ro…

“阿里巴巴云原生关注微服务、Serverless、容器、Service Mesh 等技术领域、聚焦云原生流行技术趋势、云原生大规模的落地实践,做最懂云原生开发者的公众号。”

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官问我存储金额应该用哪种数据类型,我竟这样回答

发表于 2020-05-05

前言

​ 最近在面试时,碰到这样一个问题:在问到项目部分时,面试官问我:你的项目中用到的分数、金额之类的数字是用的什么数据类型? 我没有过多思考脱口而出:double!随后面试官又问:为啥不用float?

​ 听到这个问题,脑子里竟然突然有些懵,回答道:double用着顺手所以就用了,面试过后我自己在听录音复盘时(远程线上面试)听到自己的回答不由得扶额苦笑,后面又对这一块的内容进行了回顾加深。

double和float的区别

float(单精度浮点数)和double(双精度浮点数)的主要区别如下:

​ 1)有效数字位数不同

​ 单精度浮点数有效数字为8位

​ 双精度浮点数有效数字为16位

​ 也就是说因为有效数字位数不同,所以双精度的double要比单精度的float要更精准一些。

​ 2)数值取值范围不同

​ 单精度浮点数的表示范围:-3.40E+38~3.40E+38

​ 双精度浮点数的表示范围:-1.79E+308~1.79E+308

​ 3.40E+38的意思是3.4*10的38次方,而1.79E+308指的是1.79*10的308次方,所以double的取值范围要远远大于float

​ 3)内存中占有的字节数不同

​ 单精度浮点数在内存中占4个字节

​ 双精度浮点数在内存中占8个字节

​ 也就是说双精度的double要比单精度的float更占内存

​ 4)在程序中的处理速度不同

​ 一般来说,CPU处理单精度浮点数的速度比处理双精度浮点数快

在程序中默认小数为double类型,所以如果要用float的话,必须进行强转

1
2
3
复制代码public static void main(String[] args){
float a = 1.1;
}

比如我写了上面的代码的话,在程序中就会编译报错,正确的写法应该为如下的代码:

1
2
3
4
复制代码public static void main(String[] args){
float a = (float)1.1;
float b = 1.1f;
}

手动强转或者在小数后面加f表示为float类型(f不区分大小写)

在使用float时需要注意一点:float 是8位有效数字,比如说有如下代码:

1
2
3
4
复制代码public static void main(String[] args){
float a = 1.11111111111f;
System.out.println(a);
}

最终的输出结果为:1.1111112

这里有一个疑问,无论第九位是否大于5,在取值的时候都会向第八位进1。

以上就是double和float的区别

金额到底应该用哪种数据类型?

​ 在总结double和float的区别时,我发现在真实开发中针对金额的存储并非如我之前思考的一样使用double或者float,为啥?让我们看下面一段代码:

1
2
3
4
5
6
复制代码public static void main(String[] args) {
double a=0.03;
double b=0.02;
double c=a-b;
System.out.println(c);
}

​ 对于这段代码的执行结果,大部分人可能会想肯定是0.01啊!但是运行之后会惊奇的发现结果居然是0.009999999999999998,因为float与double都是浮点数,浮点数参与的运算通常伴随着因为无法精确表示而进行的近似与舍入,所以导致结果会有一些偏差,而涉及到金额的计算是绝对不允许存在偏差的。

​ 那么应该怎么表示金额呢?

​ 有两种解决方案:第一种是存储金额时以分或厘为单位存储一个整数,第二种是使用BigDecimal这种数据类型来表示金额。

​ 对于第一种是我目前在写项目时采用的,第二种暂时并未做尝试。

总结

面试官问的小小的一个问题竟然藏有这么多玄机和学问,不由得让我汗颜,归根结底还是自己的知识面不够广。不过这也算是面试中的一些小小收获吧,能发现自己的不足并及时补足。

本文已在CSDN同步上传:面试官问我存储金额应该用哪种数据类型,我竟这样回答

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

搭建一套属于自己的Linux环境(手把手保姆级教程)

发表于 2020-05-04

一、搭建背景

本文准备从0开始,一步步搭建一套属于自己的Linux系统环境,这将是后续学Linux、用Linux、Linux环境编程、应用和项目部署、工具实验等一系列学习和实践的基石,希望对小伙伴们有帮助。

提前备好Linux编程实验环境非常重要,建议人手一套,这样以后每当学完一个理论知识需要实践时,立马就可以拿到上面去练手了。

因此本文先把环境给搭建起来!

二、Linux系统特点

开放性(开源)、多用户、多任务、良好的用户界面、优异的性能与稳定性。

  • 单用户:在登录计算机(操作系统),只允许同时登录一个用户;
  • 单任务:允许用户同时进行的操作任务数量;
  • 多用户:多个用户,在登录计算机(操作系统),允许同时登录多个用户进行操作;
  • 多任务:多个任务,允许用户同时进行多个操作任务。

说明:Windows属于单用户、多任务;Linux属于:多用户、多任务。

分支:Linux分支有很多,现在比较有名的Ubuntu、Debian、CentOS(Community Enterprise Operating System)、RedHat、SUSE等。

三、Linux系统的安装

1、安装方式

目前安装操作系统方式有2种:真机安装、虚拟机安装。

  • 真机安装:使用真实的电脑进行安装,像安装Windows操作系统一样,真机安装的结果就是替换掉当前的Windows操作系统;
  • 虚拟机安装:通过一些特定的手段,来进行模拟安装,并不会影响当前计算机的真实操作系统;

如果是学习或者测试使用,强烈建议使用虚拟机安装方式。

2、虚拟机软件

虚拟机软件:可以模拟真实的计算机环境操作计算机系统的软件。

虚拟机目前有2个比较有名的产品:VMware公司的VMwareWorkstation、Oracle公司的VirtualBox。

两者的区别:VMware Workstation属于重量级的,功能强大;VirtualBox属于轻量级的,功能简单。

说明:我们这里以VMware Workstation为例进行演示。

3、虚拟机的安装

①双击打开安装程序:

②进行下一步开始安装:

③同意许可协议:

④根据需要决定是否需要更改软件的安装位置:

点击下一步:

⑤用户体验设置:

⑥快捷方式创建的步骤:

⑦点击安装按钮:

⑧点击完成:

⑨最重要的地方,在安装完之后需要检查,检查虚拟机软件是否有安装2个虚拟网卡:

4、Linux版本的选择

版本选择:CentOS 6.5【镜像一般都是xxx.iso文件】,官网下载地址。

5、新建虚拟机

使用VMwareWorkstation:

①点击“文件”菜单,选择“新建虚拟机”选项,选择“自定义”点击下一步:

②选择兼容性,默认即可,直接下一步:

③选择镜像文件的时候选择“稍后安装操作系统”,点击下一步:

④选择需要安装的操作系统:

⑤选择虚拟机的名称和设置虚拟系统的安装位置:

⑥CPU设置:

⑦分配内存:

⑧选择网络类型,选择NAT:

NAT网络:物理主机和虚拟机进行互相通信,但其他计算机访问不了;

桥接网络:其他计算机也可以访问虚拟机中的Linux操作系统;

⑨后续默认的步骤,直接下一步:

⑩点击完成:

6、Linux操作系统安装

使用VMwareWorkstation进行安装:

a. 由于之前没有指定iso镜像文件,因此此处需要先指定系统镜像文件:

b. 运行此虚拟机:

如果启动之后出现类似提示框(不是错误框)则勾选不再提示,并且确定即可:

如果在启动时候出现下述错误,则说明电脑没有开启CPU虚拟化,如果需要开启,则需要重启计算机,并且在开启的时候进入主板的BIOS设置开启虚拟化,然后保存设置重启电脑:

c. 选择升级/安装已经存在的系统(通过↑/↓方向键)按下回车:

d. 在检测到光盘(disc)之后选择跳过完整性检测直接进行安装:

随后提示不支持的硬件,忽略直接下一步:

e. 点击下一步:

f. 选择在安装过程中使用的语言:

g. 选择键盘类型,美国式英语:

h. 选择存储设备类型:

i. 对磁盘进行空白盘的初始化操作,选择“是,忽略所有的数据”:

j. 设置网卡自动连接,依次应用 - 关闭 - 下一步:

k. 设置时区,默认亚洲/上海:

l. 设置密码,设置好了之后下一步:

m. 使用全部的磁盘空间来安装Linux系统,点击下一步:

n. 选择安装的Linux类型:

o. 选择开发 – 开发工具,前面复选框√,点击下一步:

p. 等待软件包的安装:

等待完成,点击重新引导:

q. 重新引导之后点击“前进”:

r. 在协议许可界面选择同意,然后点击前进:

s. 创建普通用户帐号(可选),然后点击前进:

t. 时间设置,设置好之后前进:

u. 关于kdump,之后点击完成:

v. 登录界面:

w. 使用root帐号登录之后的提示:

x. 看到的桌面:

7、终端

问题:在目前的桌面系统中,如果需要关机可以通过“系统”“关机”进行关机,那么后期服务器都是命令行模式的,届时这种方式将不好用,那会要怎么关机呢?

所谓终端,其实类似于Windows下cmd命令行模式。在终端中可以输入需要执行的一些命令,同样可以通过终端进行关机(注意:以后在工作中很少会去使用关机命令,会使用重启比较多)。

终端的形式:

终端组成部分:

如何使用终端命令进行关机?

在Linux中关机命令有以下几个:shutdown -h now(正常关机)、halt(关闭内存)、init 0。

8、使用备份操作系统

备份方式有2种:快照、克隆。

快照:又称还原点,就是保存在拍快照时候的系统的状态(包含了所有的内容),在后期的时候随时可以恢复。

说明:侧重在于短期备份,需要频繁备份的时候可以使用快照,做快照的时候虚拟的操作系统一般处于开启状态。

①在菜单“虚拟机”-“快照”-“拍摄快照”,输入相关信息,点击拍摄快照

②搞事情,故意损坏系统(把系统所有文件都删了):

③使用快照恢复搞事情之前的状态,找到虚拟机–快照–快照管理器:

恢复好之后的状态:

克隆:就是复制的意思,侧重长期备份,做克隆的时候是必须得关闭。

路径:先关机–右键需要克隆的虚拟机–管理–克隆

说明:克隆好的Linux系统相关密码帐号等信息与被克隆的系统一致。

以上就是搭建一套属于Linux环境的完整教程,怎么样,够详细吧!!!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【源码面经】Java源码系列-LinkedHashMap 面

发表于 2020-05-03

面试题

  1. LinkedHashMap如何实现有序的
  2. 如何用LinkedHashMap实现LRU

不想看源码解析的同学,可以直接去最下方查看答案

源码解析

LinkedHashMap在Map的基础上进行了扩展,提供了按序访问的能力。这个顺序通过accessOrder控制,可以是结点的插入顺序,也可以是结点的访问时间顺序。

LinkedHashMap还提供了removeEldestEntry方法,可以用来删除最老访问结点。

通过accessOrder和removeEldestEntry可以用来实现LRU缓存。

LinkedHashMap

如图所示,LinkedHashMap实现顺序访问的方法比较简单,在HashMap实现之外,还维护了一个双向链表。每当插入结点时,不仅要在Map中维护,还需要在链表中进行维护。HashMap中的put, get等方法都提供了一些钩子方法,如afterNodeAccess、afterNodeInsertion和afterNodeRemoval等。通过这些方法,LinkedHashMap可以对这些结点进行一些特性化的维护。

当遍历LinkedHashMap时通过遍历链表代替遍历Map中的各个槽,从而实现按序访问。

底层数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
复制代码    /**
* LinkedHashMap普通的链表结点,继承了HashMap的Node,在此基础上
* 对每个Node添加了before和after指针。LinkedHashMap在HashMap的
* 基础上,还维护了一个双向链表,链表中的结点就是Map中的每个结点,
* 通过此链表,LinkedHashMap就实现了维护结点顺序的目的
*/
static class Entry<K, V> extends HashMap.Node<K, V> {
Entry<K, V> before, after;

Entry(int hash, K key, V value, Node<K, V> next) {
super(hash, key, value, next);
}
}

/**
* 双向链表的头结点
*/
transient LinkedHashMap.Entry<K, V> head;

/**
* 双向链表的尾结点
*/
transient LinkedHashMap.Entry<K, V> tail;

/**
* true-按访问顺序(最早操作过的结点靠前)
* false-按插入顺序遍历(最早插入的结点靠前)
*
* @serial
*/
final boolean accessOrder;

Node结点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
复制代码    Node<K, V> newNode(int hash, K key, V value, Node<K, V> e) {
LinkedHashMap.Entry<K, V> p =
new LinkedHashMap.Entry<K, V>(hash, key, value, e);
// 创建一个key-value对时,不仅要放入map中,还有放入LinkedHashMap
// 内置的双向链表中,用来维护插入顺序
linkNodeLast(p);
return p;
}

Node<K, V> replacementNode(Node<K, V> p, Node<K, V> next) {
LinkedHashMap.Entry<K, V> q = (LinkedHashMap.Entry<K, V>) p;
LinkedHashMap.Entry<K, V> t =
new LinkedHashMap.Entry<K, V>(q.hash, q.key, q.value, next);
// 用t结点代替q结点在双向链表中的位置
transferLinks(q, t);
return t;
}

TreeNode<K, V> newTreeNode(int hash, K key, V value, Node<K, V> next) {
TreeNode<K, V> p = new TreeNode<K, V>(hash, key, value, next);
linkNodeLast(p);
return p;
}

TreeNode<K, V> replacementTreeNode(Node<K, V> p, Node<K, V> next) {
LinkedHashMap.Entry<K, V> q = (LinkedHashMap.Entry<K, V>) p;
TreeNode<K, V> t = new TreeNode<K, V>(q.hash, q.key, q.value, next);
transferLinks(q, t);
return t;
}

工具方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
复制代码    // 在双向链表尾部添加结点
private void linkNodeLast(LinkedHashMap.Entry<K, V> p) {
LinkedHashMap.Entry<K, V> last = tail;
tail = p;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}

// 使用dst结点覆盖src结点在双向链表中的位置
private void transferLinks(LinkedHashMap.Entry<K, V> src,
LinkedHashMap.Entry<K, V> dst) {
LinkedHashMap.Entry<K, V> b = dst.before = src.before;
LinkedHashMap.Entry<K, V> a = dst.after = src.after;
if (b == null)
head = dst;
else
b.after = dst;
if (a == null)
tail = dst;
else
a.before = dst;
}

/**
* 每次插入新Node时,是否需要删除最老的结点。
*
* @return true-删除最老结点,false-不删除
*/
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return false;
}

构造方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
复制代码    public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;
}


public LinkedHashMap(int initialCapacity) {
super(initialCapacity);
accessOrder = false;
}

public LinkedHashMap() {
super();
accessOrder = false;
}

public LinkedHashMap(Map<? extends K, ? extends V> m) {
super();
accessOrder = false;
putMapEntries(m, false);
}


/**
* 可以指定遍历结点的顺序
*
* @param accessOrder true-按访问顺序(最早操作过的结点靠前)
* false-按插入顺序遍历(最早插入的结点靠前)
*/
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}

钩子方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
复制代码    // 重写HashMap中提供给LinkedHashMap的钩子方法

/**
* HashMap 调用remove方法后,会调用这个钩子方法,e为删除的结点
*/
void afterNodeRemoval(Node<K, V> e) {
// p = e; b = p.before; a = p.after;
LinkedHashMap.Entry<K, V> p =
(LinkedHashMap.Entry<K, V>) e, b = p.before, a = p.after;

// 从双向链表中删除p结点
p.before = p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a == null)
tail = b;
else
a.before = b;
}

/**
* HashMap 调用put等方法后,会调用这个钩子方法
*
* @param evict false-table处于创建模式(即通过构造方法调用)
*/
void afterNodeInsertion(boolean evict) {
LinkedHashMap.Entry<K, V> first;

// 如果map中存在元素,且需要删除eldest元素,则从链表和Map中
// 删除双向链表头结点。removeEldestEntry在LinkedHashMap默认返回
// false。该方法可以用来实现LRU缓存
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}

/**
* HashMap 调用put, get等方法后,会调用这个钩子方法,更改最新访问时间。
* 可以用来实现LRU缓存
*
* @param e 最近操作过的结点
*/
void afterNodeAccess(Node<K, V> e) {
LinkedHashMap.Entry<K, V> last;

// 如果accessOrder为true,代表按最新遍历时间维护链表
// 则将e移至链表尾部
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K, V> p =
(LinkedHashMap.Entry<K, V>) e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}

其他

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
复制代码    public boolean containsValue(Object value) {
// 因为LinkedHashMap中使用双向链表维护了所有Node,所以只需要遍历
// 双向链表即可遍历所有Node。而不用遍历Map。

for (LinkedHashMap.Entry<K, V> e = head; e != null; e = e.after) {
V v = e.value;
if (v == value || (value != null && value.equals(v)))
return true;
}
return false;
}

public V get(Object key) {
Node<K, V> e;
// 寻找key对应结点
if ((e = getNode(hash(key), key)) == null)
return null;
// 如果需要按访问时间排序,则更新结点在双向链表中的位置
if (accessOrder)
afterNodeAccess(e);
return e.value;
}

public V getOrDefault(Object key, V defaultValue) {
Node<K, V> e;
if ((e = getNode(hash(key), key)) == null)
return defaultValue;
if (accessOrder)
afterNodeAccess(e);
return e.value;
}

public void clear() {
super.clear();
head = tail = null;
}

public void forEach(BiConsumer<? super K, ? super V> action) {
if (action == null)
throw new NullPointerException();
int mc = modCount;
// 覆写了遍历方法,用遍历双向链表代替遍历map,从而实现了按序遍历。

for (LinkedHashMap.Entry<K, V> e = head; e != null; e = e.after)
action.accept(e.key, e.value);
if (modCount != mc)
throw new ConcurrentModificationException();
}

迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
复制代码    abstract class LinkedHashIterator {
// 下一个要遍历的结点
LinkedHashMap.Entry<K, V> next;
// 上一个遍历过的结点
LinkedHashMap.Entry<K, V> current;
// 版本号
int expectedModCount;

LinkedHashIterator() {
next = head;
expectedModCount = modCount;
current = null;
}

public final boolean hasNext() {
return next != null;
}

final LinkedHashMap.Entry<K, V> nextNode() {
LinkedHashMap.Entry<K, V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
current = e;
// 遍历双向链表的下一个结点
next = e.after;
return e;
}

public final void remove() {
Node<K, V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
removeNode(hash(key), key, null, false, false);
expectedModCount = modCount;
}
}

面试题解答

  1. LinkedHashMap如何实现有序的

LinkedHashMap在HashMap的基础上,还将每个key-value对应的Node维护在了一个额外的双向链表中。

LinkedHashMap通过accessOrder可以支持按插入的顺序访问,或者按遍历的顺序访问

accessOrder

* false: 按插入顺序排序,map中每插入一个结点时,将这个结点同时放置在双向链表的结尾
* true: 按访问顺序排序,当操作map中的一个结点时,通过HashMap提供的钩子方法(`afterNodeAccess`、`afterNodeInsertion`和`afterNodeRemoval`)找到这个结点在链表中的位置,并移动到链表结尾。这样链表的头结点就是链表最久没有访问过的结点

遍历的时候,通过便利双向链表代替遍历map的每个槽,来实现顺序访问。
2. 如何用LinkedHashMap实现LRU

首先分析LRU算法有哪些特性

1. 新数据插入到链表尾部(代表最新访问);
2. 每当缓存命中(即缓存数据被访问)则将数据移到链表尾部(代表最新访问);
3. 当链表满的时候,将链表头部的数据丢弃(删除最久未访问结点);

在LinkedHashMap保证结点有序的情况下,通过设置accessOrder为true,采用按遍历顺序维护结点。

1. put方法将结点插入到双向链表尾部实现LRU特性 1;
2. 钩子方法`afterNodeAccess`实现LRU特性 2;
3. 实现removeEldestEntry方法,删除最久未访问结点。实现LRU特性 3;

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【漫画】ES原理 必知必会的倒排索引和分词 倒排索引的初衷

发表于 2020-05-03

es1

倒排索引的初衷

es2_1

倒排索引,它也是索引。索引,初衷都是为了快速检索到你要的数据。

我相信你一定知道mysql的索引,如果对某一个字段加了索引,一般来说查询该字段速度是可以有显著的提升。
每种数据库都有自己要解决的问题(或者说擅长的领域),对应的就有自己的数据结构,而不同的使用场景和数据结构,需要用不同的索引,才能起到最大化加快查询的目的。
对 Mysql 来说,是 B+ 树,对 Elasticsearch/Lucene 来说,是倒排索引。

es2_2

倒排索引是什么

刚刚胖滚猪说到图书的例子,目录和索引页,其实就很形象的可以比喻为正排索引和倒排索引。为了进一步加深理解,再看看熟悉的搜索引擎。没有搜索引擎时,我们只能直接输入一个网址,然后获取网站内容,这时我们的行为是document -> words。此谓「正向索引」。后来,我们希望能够输入一个单词,找到含有这个单词,或者和这个单词有关系的文章,即word -> documents。于是我们把这种索引,叫「反向索引」,或者「倒排索引」。
好了,我们来总结一下:

image

es3

倒排索引的实现

假如一篇文章当中,有这么一段话”胖滚猪编程让你收获快乐”,我要通过”胖滚猪”这个词来搜索到这篇文章,那么应该如何实现呢。

我们是很容易想到,可以将这篇文章的词都拆开,拆分为”胖滚猪”、”编程”、”收获”、”快乐”。注意我们把没用的词,比如”让”去掉了。这个拆分短语的过程涉及到ES的分词,另外中文分词还是比较复杂的,不像英文分词一般用空格分隔就可以。等会我们再来说分词吧,现在你只要知道,我们是会按一定规则把文章单词拆分的。
那么拆开了,怎么去找呢?自然会维护一个单词和文档的对应关系,如图:

image
es4

倒排索引的核心组成

1、单词词典:记录所有文档的单词,一般都比较大。还会记录单词到倒排列表的关联信息。
2、倒排列表:记录了单词对应的文档集合,由倒排索引项组成。倒排索引项包含如下信息:

  • 文档ID,用于获取原始信息
  • 单词频率TF,记录该单词在该文档中的出现次数,用于后续相关性算分
  • 位置Position,记录单词在文档中分词的位置,用于语句搜索(phrase query)
  • 偏移Offset,记录单词在文档的开始和结束位置,实现高亮显示

es6

ES的倒排索引

下图是 Elasticsearch 中数据索引过程的流程。ES由 Analyzer 组件对文档执行一些操作并将具体子句拆分为 token/term,简单说就是分词,然后将这些术语作为倒排索引存储在磁盘中。
image

ES的JSON文档中的每一个字段,都有自己的倒排索引,当然你可以指定某些字段不做索引,优点是这样可以节省磁盘空间。但是不做索引的话字段无法被搜索到。
注意两个关键词:分词和倒排索引。倒排索引我相信你已经懂了!分词我们马上就来聊聊!

ES的分词

还是回到我们开头的那个查询例子,毕竟胖滚猪心心念念为什么会搜出两个文档!首先我们用_analyze来分析一下ES会如何对它进行分词及倒排索引:
image

现在你是不是一目了然了呢!先不管_analyze是何方神圣,反正你看到结果了,ES将它分成了一个个字,这是ES中默认的中文分词。掌握分词要先懂两个名词:analysis与analyzer

** analysis:**

  文本分析,是将全文本转换为一系列单词的过程,也叫分词。analysis是通过analyzer(分词器)来实现的,可以使用Elasticearch内置的分词器,也可以自己去定制一些分词器。

** analyzer(分词器): **

由三部分组成:

  • Character Filter:将文本中html标签剔除掉。
  • Tokenizer:按照规则进行分词,在英文中按照空格分词
  • Token Filter:将切分的单词进行加工,小写,删除 stopwords(停顿词,a、an、the、is等),增加同义词

注意:除了在数据写入时将词条进行转换,查询的时候也需要使用相同的分析器对语句进行分析。即我们写入苹果的时候分词成了苹和果,查询苹果的时候同样也是分词成苹和果去查。
es7

ES内置分词器

  • Standard Analyzer - 默认分词器,按词切分,小写处理
  • Simple Analyzer - 按照非字母切分(符号被过滤), 小写处理
  • Stop Analyzer - 小写处理,停用词过滤(the,a,is)
  • Whitespace Analyzer - 按照空格切分,不转小写
  • Keyword Analyzer - 不分词,直接将输入当作输出
  • Patter Analyzer - 正则表达式,默认\W+(非字符分割)
  • Language - 提供了30多种常见语言的分词器
  • Customer Analyzer 自定义分词器

看概念太虚了!一定要动手实操才有用!我们可以用_analyze进行分析,会输出分词后的结果,举两个例子吧!其他的你也要自己课后动手试试哦!

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码#默认分词器 按词切分 小写处理
GET _analyze
{
"analyzer": "standard",
"text": "2 running Quick brown-foxes leap over lazy dogs in the summer evening."
}
​
#可以发现停用词被去掉了
GET _analyze
{
"analyzer": "stop",
"text": "2 running Quick brown-foxes leap over lazy dogs in the summer evening."
}

中文扩展分词器

现在来解决胖滚猪的问题,苹果明明一个词,不想让它分为两个呀!中文分词在所有搜索引擎中都是一个很大的难点,中文的句子应该是切分成一个个的词,但是一句中文,在不同的上下文,其实是不同的理解,例如: 这个苹果,不大好吃/这个苹果,不大,好吃。

有一些比较不错的中文分词插件:IK、THULAC等。我们可以试试用IK进行中文分词。

1
2
3
4
5
6
7
复制代码#安装插件
https://github.com/medcl/elasticsearch-analysis-ik/releases
在plugins目录下创建analysis-ik目录 解压zip包到当前目录 重启ES
#查看插件
bin/elasticsearch-plugin list
#查看安装的插件
GET http://localhost:9200/_cat/plugins?v

** IK分词器:支持自定义词库、支持热更新分词字典 **

  • ik_max_word: 会将文本做最细粒度的拆分,比如会将“这个苹果不大好吃”拆分为”这个,苹果,不大好,不大,好吃”等,会穷尽各种可能的组合;
  • ik_smart: 会做最粗粒度的拆分,比如会将“这个苹果不大好吃”拆分为”这个,苹果,不大,好吃”
1
2
3
4
5
6
复制代码curl -X GET "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d'
{
"analyzer" : "ik_max_word",
"text" : "这个苹果不大好吃"
}
'

es8

** 如何使用分词器 **

列举了很多的分词器,那么在实际中该如何使用呢?看看下面这个代码演示就懂啦!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码# 创建索引时候指定某个字段的分词器
PUT iktest
{
"mappings": {
"properties": {
"content": {
"type": "text",
"analyzer": "ik_smart"
}
}
}
}
# 插入一条文档
PUT iktest/_doc/1
{
"content":"这个苹果不大好吃"
}
# 测试分词效果
GET /iktest/_analyze
{
"field": "content",
"text": "这个苹果不大好吃"
}
​

注:本文来源于公众号[胖滚猪学编程],其中卡通形象来源于微信表情包”胖滚家族”,且已获作者的许可。

wchat1

本文转载自公众号【胖滚猪学编程】 用漫画让编程so easy and interesting!欢迎关注形象来源于微信表情包【胖滚家族】喜欢可以下载哦

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

计算机网络技术(五)——网络互联技术

发表于 2020-05-03

摘要

  1. 网络互联概述
  2. 网际协议(IP)
  3. IP 地址
  4. IP 路由
  5. IP 中的其他协议
  6. IPv6 协议

一、网络互联概述

要让分布在世界各地,不同类型、不同协议的网络相互连接起来,并且能流畅、高效地实现信息共享,就必须依靠网络互联技术来实现。网络互联技术是指所有能在物理和逻辑上实现不同网络相互连接的技术的总称。
实现异构网络互联的基本策略是协议转换和构建虚拟互联网络。协议转换实现异构网络之间数据分组的转换和转发;构建虚拟互联网络实现在异构网络间转发统一的虚拟互联网络的数据分组。IP网络就是此类虚拟互联网。

二、网际协议(IP)

2.1 IP概述

网际协议(Internet Protocol,IP)是TCP/IP体系中最重要的两个协议之一。正是由于IP的出现,才使分布在世界各地的不同类型的网络互联在一起。IP属于OSI参考模型的网络层,与之相关的还有3个协议:

  • 地址解析协议(Address Resolution Protocol,ARP)
  • 网际控制报文协议(Internet Control Message Protocol,ICMP)
  • 网际组管理协议(Internet Group Management Protocol,IGMP)

IP中最核心的技术是IP地址和IP路由。
IP地址是标识IP网中的计算机、路由器和子网分配设备的地址。
IP路由是指收集、计算和维护到达不同网络的路径信息,并存在路由表里。

IP的特点:

  1. IP是面向无连接的、不可靠的分组传输协议
  2. IP屏蔽了数据链路层和物理层的差异,使得数据的传输和转发更加方便
  3. IP是点对点式网络通信协议
2.2 IPv4协议报文格式

IPv4是第4版IP,是当前Internet网络主要使用的IP,其数据报的格式如下图所示。

IPv4数据报结构

各字段说明:

1)版本号:表示IP的版本号。IPv4的版本号字段值为0100(十进制4)。
2)首部长度:IP分组首部长度,包括可变长度的选项字段,以4B为单位。
3)区分服务:用来指示期望获得哪种类型的服务。只在网络提供区分服务时,才使用该字段。
4)总长度:IP分组的总字节数,包括首部和数据部分。
5)标识:标识一个IP分组。主要用途是在IP分组分片和重组过程中,用于标识属于同一源的IP分组。
6)标志位:用于IP分组的分片。DF是禁止分片标志,MF是更多分片标志。结构如下:

保留位 DF MF

7)片偏移:表示一个IP分组分片封装源IP分组数据的相对偏移量,即封装的数据从哪个字节开始。
8)生存时间(TTL):表示IP分组在网络中可以通过的路由器数(或跳数)。TTL = 0 时,路由器丢弃该IP分组。
9)协议:该IP分组封装的是哪个协议的数据包,IP利用该字段实现复用/解复用功能。
10)首部校验和:对IP分组首部的差错检测。
11)源IP地址/目的IP地址:发出/接收IP分组的主机IP地址。
12)选项字段:可以携带安全、源选路径、时间戳和路由记录等内容。
13)填充:用于补齐整个首部,符合32位对齐。

三、IP 地址

IP要连接世界各地的网络和主机,实现数据自由传输,必须要为每一个主机分配一个全球唯一的标识,这个唯一的标识称为IP地址。目前普遍使用的是IPv4版本,由一个32位的二进制数表示。IPv4经历了3各历史阶段:

1. 分类的IP地址
IP地址由两个字段构成:网络号和主机号。表示方式为:

IP地址:: = {<网络号>,<主机号>}

分为5种类别:A、B、C、D和E。

  • A类地址:网络号占8位,主机号占24位,其中网络号最高位固定为1。
  • B类地址:网络号占16位,主机号占16位,其中网络号最高2位固定为10。
  • C类地址:网络号占24位,主机号占8位,其中网络号最高3位固定为110。
  • D类地址:最高4位为1110,用于IP多播。
  • E类地址:最高4位为1111,作为保留使用。

这5类IP地址分配示意图如下。

IP地址分类示意图

随着网络规模的发展,这种分类方式会造成巨大的地址浪费,此外,两级IP地址也不够灵活,因此出现了三级IP地址结构,即子网划分。

2. 子网划分
子网划分的方法:
1)在原有两级IP地址结构基础上,从IP地址的主机号部分借用若干位作为子网号,则IP地址的结构变为了:网络号、子网号和主机号3部分。表示方式为:

IP地址 :: = {<网络号>,<子网号>,<主机号>}

2)一个拥有多个物理网络的单位,可以利用子网号,将物理网络划分为若干个子网。
3)从其他网络发送给某单位的某个主机的IP数据报,先根据其目的IP地址中的网络号找到单位网络相连接的路由器;该路由器再根据目的IP地址中的子网号找到相应的子网,最终将IP数据报发送给目的主机。

例如,某网络地址为215.195.45.0,将主机号中的3位作为子网号使用,剩下5位作为主机号,则最多可以划分出8个子网,分别是
215.195.45.0(子网号为000)、215.195.45.32(子网号为001)、215.195.45.64(子网号为010)、215.195.45.96(子网号为011)、215.195.45.128(子网号为100)、215.195.45.160(子网号为101)、215.195.45.192(子网号为110)、215.195.45.244(子网号为111),
每个子网的最大主机数为2^5 - 2 = 30个。

查找子网的方法是 ”子网掩码“ 。
子网掩码是一个32位二进制数,1对应IP地址中的网络号和子网号,0对应主机号。将IP地址与子网掩码按位进行逻辑与(AND)运算,可得子网的网络地址。

例如,IP地址为202.194.20.138,其子网掩码为 11111111 11111111 11111111 11100000,两者按位逻辑与运算为:

IP地址与子网掩码逻辑与运算

故该IP所在子网的网络地址为202.194.20.128。

3. 无分类编址CIDR
为了提高IP地址资源利用率,又出现了无分类编址方法,称为 无分类域间路由选择(Classless Inter-Domain Routing,CIDR) 。其基本思想为:

1)CIDR不再按A、B、C类进行IP区分,也不再使用子网号,它把32位的IP地址划分为两部分,前部分称为网络前缀,用来指明网络,后面部分用来指明主机。一种无分类的二级编址方式,表示方式如下:

IP地址 :: = {<网络前缀>,<主机号>}

在IP地址后面加上斜线 ”/“ 加网络前缀位数的方式来表示一个完整的IP地址,如:
202.194.20.138/27
2)网络前缀都相同的连续IP地址称为一个CIDR地址块,其中一个地址被确定,则整个地址块的最小地址和最大地址,以及地址块中的地址个数都可被确定。如IP地址为202.194.20.138/27,其前27位为网络前缀,后5位为主机号,则二进制表示为:
11001010 11000010 00010100 100 01010 (加粗的是前缀)
则该IP地址所在CIDR地址块中的最小地址为:
11001010 11000010 00010100 100 00000 (加粗的是前缀),即
202.194.20.128
最大地址为:
11001010 11000010 00010100 100 11111 (加粗的是前缀),即
202.194.20.159
该地址块中总共有2^5 = 32个地址。

CIDR优点:
路由聚合 : 可以用来简化路由器中的路由表,将众多的IP地址聚合在一个CIDR地址块中,从而减少路由表中的项目和路由器之间的信息交换;
灵活分配: 即从主机号中借用若干位来扩展网络前缀位数,从而灵活分配CIDR的地址块。

如210.32.16.0/20地址块,可以分为4个相等IP地址数的地址块,也可以分为不同IP地址数的地址块。

分为4个相同数量的IP地址数:

原始地址块 210.32.16.0/20 11010010 00100000 00010000 00000000
地址块1 210.32.16.0/22 11010010 00100000 00010000 00000000
地址块2 210.32.20.0/22 11010010 00100000 00010100 00000000
地址块3 210.32.24.0/22 11010010 00100000 00011000 00000000
地址块4 210.32.28.0/22 11010010 00100000 00011100 00000000

分为4个不同数量的IP地址数:

原始地址块 210.32.16.0/20 11010010 00100000 00010000 00000000
地址块1 210.32.16.0/21 11010010 00100000 00010000 00000000
地址块2 210.32.24.0/22 11010010 00100000 00011000 00000000
地址块3 210.32.28.0/23 11010010 00100000 00011100 00000000
地址块4 210.32.30.0/23 11010010 00100000 00011110 00000000

当路由表中包含同一个地址块内的多个子网地址,在使用CIDR路由查找时,应选择具有最长网络前缀的路由,称为 最长前缀匹配 。因为前缀越长,子网就越小,路由就越具体。

除了上面三种编址方式外,还有一些特殊的IP地址,它们称为私有地址或专用地址。这些地址只能在内部网络中使用。如下表:

私有地址类别 范围
A类 10.0.0.0 ~ 10.255.255.255(或10.0.0.0/8)
B类 172.16.0.0 ~ 172.31.255.255(或172.16.0.0/12)
C类 192.168.0.0 ~ 192.168.255.255(或192.168.0.0/16)

如果使用私有地址的主机与网络外的主机通信,必须进行地址转换,转换方法称为网络地址转换协议(Network Address Translation,NAT)。

四、IP路由

为IP数据报寻找合适的通信路径并且将其转发出去的过程称为IP路由,是由路由器实现的。

4.1 路由器的结构及功能

路由器结构分为输入/输出端口、交换结构和路由处理器。

1)路由处理器: 负责执行路由器指令,包括路由协议、路由运算、路由表更新维护。
2)输入端口: 根据接收到的IP数据报的目的IP地址检索路由表,指定数据报交换到哪个输出端口,然后交给交换结构处理。
3)交换结构: 将输入端口的IP数据报交换到指定的输出端口。主要包括内存交换、总线交换和高级交叉 “网络” 交换3种交换结构。
4)输出端口: 重新封装IP数据报到数据链路层的数据帧中,并通过物理层发送出去。

4.2 路由表与路由转发

“路由” 与 “转发” 是路由器最重要的两项功能。通过静态(人工方式)或动态(路由协议)获取路由信息并保存在路由表中,供数据转发时使用。

路由表基本结构:
1)目的网络与子网掩码: 准确描述一个目的网络。
2)下一跳: 表示到达该目的网络的路径的下一个邻居结点的接口IP地址。
3)接口: 应从哪个接口发送IP分组到目的网络。

最长前缀匹配原则: 路由器对于接收到的IP数据报,将其目的IP地址与路由表中各路由项的掩码按位进行逻辑与运算,将结果与路由表里的目的网络比较,如果出现多个与目的网络一致的情况,则选择其中网络前缀最长的一项作为路由转发的依据。

4.3 路由算法

1. 静态路由与动态路由
静态路由是人工把路由信息配置到路由表中;动态路由根据路由协议动态收集网络状况信息,然后按照某种路由算法计算最佳路由并更新路由表。

2. 距离-向量路由算法
是一种仅需要网络 “局部” 信息、异步的、迭代的、分散式的路由算法。每个路由器周期性地向邻居通告形如 <目的网络,距离> 的距离向量,每个路由器当收到邻居的距离向量或检测到本地链路的费用变化时,根据Bellman-Ford计算经过哪个邻居可以到达每个目的网络的最小距离,更新字节的距离向量与路由表,并把更新的距离向量交换给其所有的邻居路由器。经过多次迭代,确定到达每个目的网络的最佳路由。
为了避免出现无穷计数问题,解决方法有:毒性逆转(Poisoned Reverse)、定义最大有效距离、水平分割和阻碍时钟等。

3. 链路状态路由算法
将网络抽象为一个图,然后利用Dijkstra算法求最短路径。每个路由器检测并收集直接相连链路的费用及直接相连路由器的IP地址信息,构造链路状态分组,广播扩散给网络中其他所有路由器。每个路由器维护一个链路状态数据库,根据库中链路状态信息构建网络拓扑图,利用Dijkstra算法求最短路径,确定最佳路由。

4. 层次路由
当网络规模很大时,抽象的网络拓扑图不再适用,最有效的解决方案是层次划分路由。将大规模互联网按组织边界、管理边界、网络技术边界或功能边界划分为多个自治系统(AS),自治系统之间通过网关路由器相关连接。层次路由将大规模互联网路由划分为两层:自治系统内路由和自治系统间路由。

4.4 路由协议

1. 自治系统(Autonomous system,AS)
自治系统是在统一技术管理下的一组路由器,这些路由器使用相同的AS内部路由选择协议。

2. 域内/域间路由
域内路由协议即AS内部使用的协议,称为内部网关协议(Interior Gateway Protocol,IGP),如RIP和OSPF协议。
域间路由协议称为外部路由网关协议(External Gateway Protocol,EGP),实现不同自治系统间交换路由信息,如BGP4协议。

3. RIP 路由协议
RIP是一种分布式的基于距离向量的IGP;RIP要求AS内每一个路由器都维护从它自己到其他每一个目的网络的距离向量。

4. OSPF 路由协议
OSPF(Open Shortest Path First,开放式最短路径优先),采用Dijkstra算法,收集与本路由器相邻的所有路由器的链路状态,并向本自治系统内的所有路由器广播发送链路状态信息,称为 “洪泛” 。
为了使OSPF能在大规模网络使用,可以将一个自治系统进一步划分若干各区域。这样就减少洪泛交换链路状态信息的通信量。

5. BGP 协议
BGP是不同自治系统间交换路由信息的协议。它是力求寻找一条能够到达目的网络且比较好的路由,而并非要寻找一条最佳路由。
BGP在TCP上建立BGP会话来交换路由信息,会话交换包括4种类型的报文。
1)打开(OPEN)报文,用来与相邻的另一个BGP发言人建立关系。
2)更新(UPDATE)报文,用来发送某一路由信息。
3)保活(KEEPALIVE)报文,用来确认打开报文和周期性证实邻站关系。
4)通知(NOTIFICATION)报文,用来发送检测到的差错。

五、IP中的其他协议

5.1 地址解析协议(ARP)

使用IP地址通信时,发送数据的主机和转发数据的路由器都必须要知道IP地址所对应的硬件地址才能将通信具体实现。地址解析协议(ARP)就是解决这一问题的机制。

ARP基本思想是在每一台主机中设置专用内存区域,称为ARP高速缓存,里面有该主机所在局域网中各个主机和路由器的IP地址和硬件地址的映射表,这个表会经常更新。

ARP查找硬件地址的过程:
假设局域网中主机A向主机B发送IP数据报
1)主机A首先在局域网中广播发送一个ARP请求分组,内容形象的描述为:“我的IP地址是A,硬件地址是a,我想知道IP地址为B的硬件地址” 。本局域网中的所有运行ARP的主机都会收到该ARP请求。
2)主机B收到该请求后,发现请求分组中的IP地址与自己IP一致,则将主机A的硬件地址写入自己的ARP高速缓存中,方便今后向A发送数据;其他主机发现与自己IP不符就丢弃。主机B收到ARP请求后,创建一个响应分组,并将自己硬件地址写入响应分组,然后直接发送给A。
3)主机A收到主机B的ARP响应分组后,将分组中主机B的硬件地址写入自己ARP高速缓存里。

为防止局域网中主机地址变更,ARP高速缓存中每一个地址映射都有一个生存时间(几十分钟),超时就被删除,然后再重新运行ARP查找主机硬件地址。

5.2 动态主机配置协议(DHCP)

一台新进算计要接入Internet,需要对其配置IP地址、子网掩码、域名等,在较大规模局域网内如果人工为每台配置则相对繁琐,动态主机配置协议就是用来解决这种问题的机制。

基本思想是:在一个网络内部设置一台DHCP服务器,其中保管着该网络所管辖的IP地址及其他配置信息;当有一台计算机新接入该网络时,它在开启后向该网络广播发送一个DHCP发现报文,目的IP地址全为1,源IP地址全为0。DHCP服务器收到此报文后,从自己保存的IP地址中取出一个,与配置信息一起,通过DHCP提供报文发送给这台计算机,从而为这台计算机分配一个新的IP地址及配置信息。

5.3 网际控制报文协议(ICMP)

在数据传送过程中经常会遇到延迟、丢失等异常情况,为了使通信双方能够知道异常情况,并及时调整和控制,网际控制报文协议应运而生。
它通过让主机或路由器发送ICMP报文,将网络传输过程中的差错和异常情况报告给参与数据通信的相关主机。ICMP报文作为IP数据报的数据部分,封装在IP数据报中,其报文格式为:

ICMP报文格式

ICMP报文有两种类型:差错报告报文和询问报文。

ICMP 差错报告报文有5种情况:
1)终点不可达。当路由器或主机不能交付数据报时,向源点发送终点不可达报文。
2)源点抑制。当路由器或主机由于拥塞而丢弃数据报时,向源点发送抑制报文。
3)时间超时。当收到生存时间(TTL)值为0的数据报时,丢弃并向源点发送超时报文。
4)参数问题。当数据报中有字段不正确时,丢弃并向源点发送参数问题报文。
5)改变路由。当路由器的路由表发生变化时,向主机发送改变路由报文。

ICMP 询问报文有2种情况:
1)回送请求和回答。由主机向一个特定目的主机发出的询问。收到此报文的主机必须给源主机发送回送回答报文,用于测试目的站是否可以到达。典型应用是ping命令。
2)时间戳请求和回答。用于网络中的主机请其他主机回答当前日期和时间,用于同步时钟和测量时间。

5.4 网际组管理协议(IGMP)

用于管理IP多播。IP多播是由一个源点向多个终点发送数据业务。

IP多播过程如下图所示。

IP多播示意图

假设3个不同局域网的主机A、主机B和主机C均属于一个多播组,其多播地址为224.30.152.62。当服务器要向这个多播组发送IP多播时,只需将IP分组发送到多播路由器R中,R根据多播路由协议,将IP分组复制并转发到多播组成员主机A、主机B和主机C所在局域网的多播路由器中,即R1、R2和R3,从而发送到主机A、主机B和主机C。

IGMP的作用是管理某个多播分组下的主机(加入或退出分组)。

IGMP的工作过程分2个阶段:
1)当某个主机加入一个新的多播组时,该主机向多播组的多播地址发送一个IGMP报文。本地多播路由器收到该报文后,根据多播路由协议将多播组成员信息告知其他多播路由器。
2)多播路由器周期性探寻本地局域网上的主机,确定是否还属于多播组成员。如果几次探寻后没有一个主机回应,则认为本地局域网内主机都已离开了此分组,不再将组成员信息发给其他多播路由器。

六、IPv6 协议

因IPv4 协议面临者IP地址资源严重不足的问题,IETF于1995年提出了IPv6协议。

IPv6 采用了新的首部格式,基本首部固定40B长度,如下图所示。

IPv6基本首部格式

首部字段说明:

  • 版本号: 指明IP版本,在IPv6中该值为0110(十进制6).
  • 通信业务类型: 用于区分不同的IPv6数据报的类别或优先级。
  • 流标号: IPv6提出了数据业务流的概念,将某特定源点到终点的一系列实时数据报(如音频、视频等)定义为流,属于同一个流的数据报,其流标号相同。
  • 有效载荷长度: 指明IPv6数据报除基本首部外的字节数,最大值为64KB。
  • 下一个首部: 当没有扩展首部时,下一个首部指明基本首部后面的数据所属于的上层协议;当有扩展首部时,用于表示后面第一个扩展首部的类型。
  • 跳数限制: 用来防止数据报在网络中无限制地被转发。数据报被源点发出后,每经过一个路由器,该值就减1,当为0时,数据报被丢弃。
  • 源地址和目的地址: 表示数据报的源点和终点。

IPv6 的表示方法:
IPv6 的地址占128位,最多可提供3.4 x 10^{38} 个IP地址。表达方式通常是采用冒号分隔的十六进制,把每16位的值用十六进制数表示,各个数之间用冒号隔开,例如一个用冒号十六进制表示的IPv6地址:45EE:0000:78AC:FFFF:2A9B:0000:FFFF:62F4。

总结

  • 介绍了网络互联是什么
  • IP的特点有哪些以及IPv4 协议报文格式
  • IPv4 地址3个阶段的编址方式:分类编址、子网划分、无分类编址CIDR
  • 路由器的结构及功能是啥,如何根据路由表转发数据,以及常用的路由算法和协议
  • IP中其他重要协议:ARP、DHCP、ICMP和IGMP 的功能和工作原理
  • IPv6 的报文结构及表示方法

luck

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

c++11新特性之std function和lambda表

发表于 2020-05-03

c++11新增了std::function、std::bind、lambda表达式等封装使函数调用更加方便。

std::function

讲std::function前首先需要了解下什么是可调用对象

满足以下条件之一就可称为可调用对象:

  • 是一个函数指针
  • 是一个具有operator()成员函数的类对象(传说中的仿函数),lambda表达式
  • 是一个可被转换为函数指针的类对象
  • 是一个类成员(函数)指针
  • bind表达式或其它函数对象

而std::function就是上面这种可调用对象的封装器,可以把std::function看做一个函数对象,用于表示函数这个抽象概念。std::function的实例可以存储、复制和调用任何可调用对象,存储的可调用对象称为std::function的目标,若std::function不含目标,则称它为空,调用空的std::function的目标会抛出std::bad_function_call异常。

使用参考如下实例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
c++复制代码std::function<void(int)> f; // 这里表示function的对象f的参数是int,返回值是void
#include <functional>
#include <iostream>

struct Foo {
Foo(int num) : num_(num) {}
void print_add(int i) const { std::cout << num_ + i << '\n'; }
int num_;
};

void print_num(int i) { std::cout << i << '\n'; }

struct PrintNum {
void operator()(int i) const { std::cout << i << '\n'; }
};

int main() {
// 存储自由函数
std::function<void(int)> f_display = print_num;
f_display(-9);

// 存储 lambda
std::function<void()> f_display_42 = []() { print_num(42); };
f_display_42();

// 存储到 std::bind 调用的结果
std::function<void()> f_display_31337 = std::bind(print_num, 31337);
f_display_31337();

// 存储到成员函数的调用
std::function<void(const Foo&, int)> f_add_display = &Foo::print_add;
const Foo foo(314159);
f_add_display(foo, 1);
f_add_display(314159, 1);

// 存储到数据成员访问器的调用
std::function<int(Foo const&)> f_num = &Foo::num_;
std::cout << "num_: " << f_num(foo) << '\n';

// 存储到成员函数及对象的调用
using std::placeholders::_1;
std::function<void(int)> f_add_display2 = std::bind(&Foo::print_add, foo, _1);
f_add_display2(2);

// 存储到成员函数和对象指针的调用
std::function<void(int)> f_add_display3 = std::bind(&Foo::print_add, &foo, _1);
f_add_display3(3);

// 存储到函数对象的调用
std::function<void(int)> f_display_obj = PrintNum();
f_display_obj(18);
}

从上面可以看到std::function的使用方法,当给std::function填入合适的参数表和返回值后,它就变成了可以容纳所有这一类调用方式的函数封装器。std::function还可以用作回调函数,或者在C++里如果需要使用回调那就一定要使用std::function,特别方便,这方面的使用方式大家可以读下我之前写的关于线程池和定时器相关的文章。

std::bind

使用std::bind可以将可调用对象和参数一起绑定,绑定后的结果使用std::function进行保存,并延迟调用到任何我们需要的时候。

std::bind通常有两大作用:

  • 将可调用对象与参数一起绑定为另一个std::function供调用
  • 将n元可调用对象转成m(m < n)元可调用对象,绑定一部分参数,这里需要使用std::placeholders

具体示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
c++复制代码#include <functional>
#include <iostream>
#include <memory>

void f(int n1, int n2, int n3, const int& n4, int n5) {
std::cout << n1 << ' ' << n2 << ' ' << n3 << ' ' << n4 << ' ' << n5 << std::endl;
}

int g(int n1) { return n1; }

struct Foo {
void print_sum(int n1, int n2) { std::cout << n1 + n2 << std::endl; }
int data = 10;
};

int main() {
using namespace std::placeholders; // 针对 _1, _2, _3...

// 演示参数重排序和按引用传递
int n = 7;
// ( _1 与 _2 来自 std::placeholders ,并表示将来会传递给 f1 的参数)
auto f1 = std::bind(f, _2, 42, _1, std::cref(n), n);
n = 10;
f1(1, 2, 1001); // 1 为 _1 所绑定, 2 为 _2 所绑定,不使用 1001
// 进行到 f(2, 42, 1, n, 7) 的调用

// 嵌套 bind 子表达式共享占位符
auto f2 = std::bind(f, _3, std::bind(g, _3), _3, 4, 5);
f2(10, 11, 12); // 进行到 f(12, g(12), 12, 4, 5); 的调用

// 绑定指向成员函数指针
Foo foo;
auto f3 = std::bind(&Foo::print_sum, &foo, 95, _1);
f3(5);

// 绑定指向数据成员指针
auto f4 = std::bind(&Foo::data, _1);
std::cout << f4(foo) << std::endl;

// 智能指针亦能用于调用被引用对象的成员
std::cout << f4(std::make_shared<Foo>(foo)) << std::endl;
}

lambda表达式

lambda表达式可以说是c++11引用的最重要的特性之一,它定义了一个匿名函数,可以捕获一定范围的变量在函数内部使用,一般有如下语法形式:

1
c++复制代码auto func = [capture] (params) opt -> ret { func_body; };

其中func是可以当作lambda表达式的名字,作为一个函数使用,capture是捕获列表,params是参数表,opt是函数选项(mutable之类), ret是返回值类型,func_body是函数体。

一个完整的lambda表达式:

1
2
3
c++复制代码auto func1 = [](int a) -> int { return a + 1; };
auto func2 = [](int a) { return a + 2; };
cout << func1(1) << " " << func2(2) << endl;

如上代码,很多时候lambda表达式返回值是很明显的,c++11允许省略表达式的返回值定义。

lambda表达式允许捕获一定范围内的变量:

  • []不捕获任何变量
  • [&]引用捕获,捕获外部作用域所有变量,在函数体内当作引用使用
  • [=]值捕获,捕获外部作用域所有变量,在函数内内有个副本使用
  • [=, &a]值捕获外部作用域所有变量,按引用捕获a变量
  • [a]只值捕获a变量,不捕获其它变量
  • [this]捕获当前类中的this指针

lambda表达式示例代码:

1
2
3
4
5
6
c++复制代码int a = 0;
auto f1 = [=](){ return a; }; // 值捕获a
cout << f1() << endl;

auto f2 = [=]() { return a++; }; // 修改按值捕获的外部变量,error
auto f3 = [=]() mutable { return a++; };

代码中的f2是编译不过的,因为我们修改了按值捕获的外部变量,其实lambda表达式就相当于是一个仿函数,仿函数是一个有operator()成员函数的类对象,这个operator()默认是const的,所以不能修改成员变量,而加了mutable,就是去掉const属性。

还可以使用lambda表达式自定义stl的规则,例如自定义sort排序规则:

1
2
3
4
5
6
7
8
9
c++复制代码struct A {
int a;
int b;
};

int main() {
vector<A> vec;
std::sort(vec.begin(), vec.end(), [](const A &left, const A &right) { return left.a < right.a; });
}

总结

std::function和std::bind使得我们平时编程过程中封装函数更加的方便,而lambda表达式将这种方便发挥到了极致,可以在需要的时间就地定义匿名函数,不再需要定义类或者函数等,在自定义STL规则时候也非常方便,让代码更简洁,更灵活,提高开发效率。

参考资料

zh.cppreference.com/w/cpp/utili…

zh.cppreference.com/w/cpp/utili…

zh.cppreference.com/w/cpp/langu…

zh.cppreference.com/w/cpp/named…

blog.csdn.net/m_buddy/art…

《深入应用c++11:代码优化与工程级应用》

《Effective Modern C++》

更多文章,请关注我的V X 公 主 号:程序喵大人,欢迎交流。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…816817818…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%