Mark blog

知行合一 划水归档

为了解决在21世纪多核和网络化环境下越来越复杂的编程问题而发明了Go语言,编程语言的演化就像生物物种的演化一样,一个成功的编程语言的后代都会继承他们祖先的优点,当然有时多种语言杂合也会产生令人惊讶的特性,还有一些激进的新特性可能并没有先例,我们可以通过观察编程语言和软硬件环境是如何相互促进,相互影响的演化过程而学到很多.

Go语言有时候被描述成”C类似语言”或者”21世纪的C语言”.Go从C语言继承了相似的表达语法,流程控制,基础数据类型,调用参数传值,指针等很多思想,还有C语言一直所看重的编译后机器码的运行效率以及和现有操作系统的无缝适配.

开始一个 Hello World

文件名后缀 hello.go

1
2
3
4
5
6
7
package main //声明本文件的package名

import "fmt" //import语言的fmt库——用于输出

func main() {
fmt.Println("hello world")
}

运行

有两种方式可以运行

  • 解释执行(将源码编译成a.out再执行)
1
2
$go run hello.go
hello world
  • 编译执行
1
2
3
4
5
6
7
$go build hello.go

$ls
hello hello.go

$./hello
hello world

自己的package

可以使用GOPATH环境变量,或是使用相对路径来import你自己的package。

Go的规约是这样的:

1)在import中,你可以使用相对路径,如 ./或 ../ 来引用你的package

2)如果没有使用相对路径,那么,go会去找$GOPATH/src/目录。

使用相对路径:

1
import` `"./haoel"`  `//import当前目录里haoel子目录里的所有的go文件

使用GOPATH路径

1
`import` `"./haoel"`  `//import当前目录里haoel子目录里的所有的go文件`

fmt输出格式

fmt包和libc里的那堆使用printf, scanf,fprintf,fscanf 很相似。下面的东西对于C程序员不会陌生。

注意:println不支持,printf才支持%式的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"
import "math"

func main() {
fmt.Println("hello world")

fmt.Printf("%t\n", 1==2)
fmt.Printf("二进制:%b\n", 255)
fmt.Printf("八进制:%o\n", 255)
fmt.Printf("十六进制:%X\n", 255)
fmt.Printf("十进制:%d\n", 255)
fmt.Printf("浮点数:%f\n", math.Pi)
fmt.Printf("字符串:%s\n", "hello world")
}

也可以使用如\n\t\r这样的和C语言一样的控制字符

变量和常量

变量的声明很像 javascript,使用 var关键字。注意:go是静态类型的语言,下面是代码:

1
2
3
4
5
6
7
8
//声明初始化一个变量
var x int = 100
var str string = "hello world"</pre>
//声明初始化多个变量
var i, j, k int = 1, 2, 3

//不用指明类型,通过初始化值来推导
var b = true //bool型

还有一种定义变量的方式

1
x := 100 //等价于 var x int = 100;

常量很简单,使用const关键字:

1
2
const s string = "hello world"
const pi float32 = 3.1415926

数组

直接看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
var a [5]int
fmt.Println("array a:", a)

a[1] = 10
a[3] = 30
fmt.Println("assign:", a)

fmt.Println("len:", len(a))

b := [5]int{1, 2, 3, 4, 5}
fmt.Println("init:", b)

var c [2][3]int
for i := 0; i < 2; i++ {
for j := 0; j < 3; j++ {
c[i][j] = i + j
}
}
fmt.Println("2d: ", c)
}

运行结果:

1
2
3
4
5
array a: [0 0 0 0 0]
assign: [0 10 0 30 0]
len: 5
init: [1 2 3 4 5]
2d: [[0 1 2] [1 2 3]]

数组的切片操作

这个就和python类似了

1
2
3
4
5
6
7
8
9
10
a := [5]int{1, 2, 3, 4, 5}

b := a[2:4] // a[2] 和 a[3],但不包括a[4]
fmt.Println(b)

b = a[:4] // 从 a[0]到a[4],但不包括a[4]
fmt.Println(b)

b = a[2:] // 从 a[2]到a[4],且包括a[2]
fmt.Println(b)

分支循环语句

if语句

注意:if 语句没有圆括号,而必需要有花括号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//if 语句
if x % 2 == 0 {
//...
}
//if - else
if x % 2 == 0 {
//偶数...
} else {
//奇数...
}

//多分支
if num < 0 {
//负数
} else if num == 0 {
//零
} else {
//正数
}

switch 语句

注意:switch语句没有break,还可以使用逗号case多个值

1
2
3
4
5
6
7
8
9
10
11
12
switch i {
case 1:
fmt.Println("one")
case 2:
fmt.Println("two")
case 3:
fmt.Println("three")
case 4,5,6:
fmt.Println("four, five, six")
default:
fmt.Println("invalid value!")
}

for 语句

前面你已见过了,下面再来看看for的三种形式:(注意:Go语言中没有while)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//经典的for语句 init; condition; post
for i := 0; i<10; i++{
fmt.Println(i)
}

//精简的for语句 condition
i := 1
for i<10 {
fmt.Println(i)
i++
}

//死循环的for语句 相当于for(;;)
i :=1
for {
if i>10 {
break
}
i++
}

关于分号

从上面的代码我们可以看到代码里没有分号。其实,和C一样,Go的正式的语法使用分号来终止语句。和C不同的是,这些分号由词法分析器在扫描源代码过程中使用简单的规则自动插入分号,因此输入源代码多数时候就不需要分号了

规则是这样的:如果在一个新行前方的最后一个标记是一个标识符(包括像intfloat64这样的单词)、一个基本的如数值这样的文字、或以下标记中的一个时,会自动插入分号:

1
break continue fallthrough return ++ -- ) }

通常Go程序仅在for循环语句中使用分号,以此来分开初始化器、条件和增量单元。如果你在一行中写多个语句,也需要用分号分开。

注意无论任何时候,你都不应该将一个控制结构((if、for、switch或select)的左大括号放在下一行。如果这样做,将会在大括号的前方插入一个分号,这可能导致出现不想要的结果

map

map在别的语言里可能叫哈希表或叫dict,下面是和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
func main(){
m := make(map[string]int) //使用make创建一个空的map

m["one"] = 1
m["two"] = 2
m["three"] = 3

fmt.Println(m) //输出 map[three:3 two:2 one:1] (顺序在运行时可能不一样)
fmt.Println(len(m)) //输出 3

v := m["two"] //从map里取值
fmt.Println(v) // 输出 2

delete(m, "two")
fmt.Println(m) //输出 map[three:3 one:1]

m1 := map[string]int{"one": 1, "two": 2, "three": 3}
fmt.Println(m1) //输出 map[two:2 three:3 one:1] (顺序在运行时可能不一样)

for key, val := range m1{
fmt.Printf("%s => %d \n", key, val)
/*输出:(顺序在运行时可能不一样)
three => 3
one => 1
two => 2*/
}
}

指针

Go语言一样有指针,看代码

1
2
3
4
5
6
7
8
9
10
11
12
var i int = 1
var pInt *int = &i
//输出:i=1 pInt=0xf8400371b0 *pInt=1
fmt.Printf("i=%d\tpInt=%p\t*pInt=%d\n", i, pInt, *pInt)

*pInt = 2
//输出:i=2 pInt=0xf8400371b0 *pInt=2
fmt.Printf("i=%d\tpInt=%p\t*pInt=%d\n", i, pInt, *pInt)

i = 3
//输出:i=3 pInt=0xf8400371b0 *pInt=3
fmt.Printf("i=%d\tpInt=%p\t*pInt=%d\n", i, pInt, *pInt)

Go具有两个分配内存的机制,分别是内建的函数new和make。他们所做的事不同,所应用到的类型也不同,这可能引起混淆,但规则却很简单。

参考自:

GO 语言简介(上)— 语法

https://coolshell.cn/articles/8460.html

在Android开发中,我们经常会遇到这样一种情况:在UI界面上进行某项操作后要执行一段很耗时的代码,比如我们在界面上点击了一个”下载“按钮,那么我们需要执行网络请求,这是一个耗时操作,因为不知道什么时候才能完成.为了保证不影响UI线程,所以我们会创建一个新的线程去执行我们的耗时的代码.当我们的耗时操作完成时,我们需要更新UI界面以告知用户操作完成了.所以我们可能会写出如下的代码:

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
package ispring.com.testhandler;

import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;


public class MainActivity extends Activity implements Button.OnClickListener {

private TextView statusTextView = null;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
statusTextView = (TextView)findViewById(R.id.statusTextView);
Button btnDownload = (Button)findViewById(R.id.btnDownload);
btnDownload.setOnClickListener(this);
}

@Override
public void onClick(View v) {
DownloadThread downloadThread = new DownloadThread();
downloadThread.start();
}

class DownloadThread extends Thread{
@Override
public void run() {
try{
System.out.println("开始下载文件");
//此处让线程DownloadThread休眠5秒中,模拟文件的耗时过程
Thread.sleep(5000);
System.out.println("文件下载完成");
//文件下载完成后更新UI
MainActivity.this.statusTextView.setText("文件下载完成");
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
}

上面的代码演示了单击”下载“按钮后会启动一个新的线程去执行实际的下载操作,执行完毕后更新UI界面.但是在实际运行到代码MainActivity.this.statusTextView.setText(“文件下载完成”)时,会报错如下,系统崩溃退出:
android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
错误的意思是只有创建View的原始线程才能更新View.出现这样错误的原因是Android中的View不是线程安全的,在Android应用启动时,会自动创建一个线程,即程序的主线程,主线程负责UI的展示、UI事件消息的派发处理等等,因此主线程也叫做UI线程,statusTextView是在UI线程中创建的,当我们在DownloadThread线程中去更新UI线程中创建的statusTextView时自然会报上面的错误.Android的UI控件是非线程安全的,其实很多平台的UI控件都是非线程安全的,比如C#的.Net Framework中的UI控件也是非线程安全的,所以不仅仅在Android平台中存在从一个新线程中去更新UI线程中创建的UI控件的问题.不同的平台提供了不同的解决方案以实现跨线程跟新UI控件,Android为了解决这种问题引入了Handler机制.

那么Handler到底是什么呢?Handler是Android中引入的一种让开发者参与处理线程中消息循环的机制.每个Hanlder都关联了一个线程,每个线程内部都维护了一个消息队列MessageQueue,这样Handler实际上也就关联了一个消息队列.可以通过Handler将Message和Runnable对象发送到该Handler所关联线程的MessageQueue(消息队列)中,然后该消息队列一直在循环拿出一个Message,对其进行处理,处理完之后拿出下一个Message,继续进行处理,周而复始.当创建一个Handler的时候,该Handler就绑定了当前创建Hanlder的线程.从这时起,该Hanlder就可以发送Message和Runnable对象到该Handler对应的消息队列中,当从MessageQueue取出某个Message时,会让Handler对其进行处理.

Handler可以用来在多线程间进行通信,在另一个线程中去更新UI线程中的UI控件只是Handler使用中的一种典型案例,除此之外,Handler可以做很多其他的事情.每个Handler都绑定了一个线程,假设存在两个线程ThreadA和ThreadB,并且HandlerA绑定了 ThreadA,在ThreadB中的代码执行到某处时,出于某些原因,我们需要让ThreadA执行某些代码,此时我们就可以使用Handler,我们可以在ThreadB中向HandlerA中加入某些信息以告知ThreadA中该做某些处理了.由此可以看出,Handler是Thread的代言人,是多线程之间通信的桥梁,通过Handler,我们可以在一个线程中控制另一个线程去做某事.

Handler提供了两种方式解决我们在本文一开始遇到的问题(在一个新线程中更新主线程中的UI控件),一种是通过post方法,一种是调用sendMessage方法.

a. 使用post方法,代码如下:

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
package ispring.com.testhandler;

import android.app.Activity;
import android.os.Bundle;
import android.os.Handler;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;


public class MainActivity extends Activity implements Button.OnClickListener {

private TextView statusTextView = null;

//uiHandler在主线程中创建,所以自动绑定主线程
private Handler uiHandler = new Handler();

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
statusTextView = (TextView)findViewById(R.id.statusTextView);
Button btnDownload = (Button)findViewById(R.id.btnDownload);
btnDownload.setOnClickListener(this);
System.out.println("Main thread id " + Thread.currentThread().getId());
}

@Override
public void onClick(View v) {
DownloadThread downloadThread = new DownloadThread();
downloadThread.start();
}

class DownloadThread extends Thread{
@Override
public void run() {
try{
System.out.println("DownloadThread id " + Thread.currentThread().getId());
System.out.println("开始下载文件");
//此处让线程DownloadThread休眠5秒中,模拟文件的耗时过程
Thread.sleep(5000);
System.out.println("文件下载完成");
//文件下载完成后更新UI
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Runnable thread id " + Thread.currentThread().getId());
MainActivity.this.statusTextView.setText("文件下载完成");
}
};
uiHandler.post(runnable);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
}

我们在Activity中创建了一个Handler成员变量uiHandler,Handler有个特点,在执行new Handler()的时候,默认情况下Handler会绑定当前代码执行的线程,我们在主线程中实例化了uiHandler,所以uiHandler就自动绑定了主线程,即UI线程.当我们在DownloadThread中执行完耗时代码后,我们将一个Runnable对象通过post方法传入到了Handler中,Handler会在合适的时候让主线程执行Runnable中的代码,这样Runnable就在主线程中执行了,从而正确更新了主线程中的UI.

通过输出结果可以看出,Runnable中的代码所执行的线程ID与DownloadThread的线程ID不同,而与主线程的线程ID相同,因此我们也由此看出在执行了Handler.post(Runnable)这句代码之后,运行Runnable代码的线程与Handler所绑定的线程是一致的,而与执行Handler.post(Runnable)这句代码的线程(DownloadThread)无关.

b. 使用sendMessage方法,代码如下:

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
73
74
75
76
77
78
79
80
81
82
package ispring.com.testhandler;

import android.app.Activity;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;


public class MainActivity extends Activity implements Button.OnClickListener {

private TextView statusTextView = null;

//uiHandler在主线程中创建,所以自动绑定主线程
private Handler uiHandler = new Handler(){
@Override
public void handleMessage(Message msg) {
switch (msg.what){
case 1:
System.out.println("handleMessage thread id " + Thread.currentThread().getId());
System.out.println("msg.arg1:" + msg.arg1);
System.out.println("msg.arg2:" + msg.arg2);
MainActivity.this.statusTextView.setText("文件下载完成");
break;
}
}
};

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
statusTextView = (TextView)findViewById(R.id.statusTextView);
Button btnDownload = (Button)findViewById(R.id.btnDownload);
btnDownload.setOnClickListener(this);
System.out.println("Main thread id " + Thread.currentThread().getId());
}

@Override
public void onClick(View v) {
DownloadThread downloadThread = new DownloadThread();
downloadThread.start();
}

class DownloadThread extends Thread{
@Override
public void run() {
try{
System.out.println("DownloadThread id " + Thread.currentThread().getId());
System.out.println("开始下载文件");
//此处让线程DownloadThread休眠5秒中,模拟文件的耗时过程
Thread.sleep(5000);
System.out.println("文件下载完成");
//文件下载完成后更新UI
Message msg = new Message();
//虽然Message的构造函数式public的,我们也可以通过以下两种方式通过循环对象获取Message
//msg = Message.obtain(uiHandler);
//msg = uiHandler.obtainMessage();

//what是我们自定义的一个Message的识别码,以便于在Handler的handleMessage方法中根据what识别
//出不同的Message,以便我们做出不同的处理操作
msg.what = 1;

//我们可以通过arg1和arg2给Message传入简单的数据
msg.arg1 = 123;
msg.arg2 = 321;
//我们也可以通过给obj赋值Object类型传递向Message传入任意数据
//msg.obj = null;
//我们还可以通过setData方法和getData方法向Message中写入和读取Bundle类型的数据
//msg.setData(null);
//Bundle data = msg.getData();

//将该Message发送给对应的Handler
uiHandler.sendMessage(msg);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
}

通过Message与Handler进行通信的步骤是:

  1. 重写Handler的handleMessage方法,根据Message的what值进行不同的处理操作
  2. 创建Message对象
    虽然Message的构造函数式public的,我们还可以通过Message.obtain()或Handler.obtainMessage()来获得一个Message对象(Handler.obtainMessage()内部其实调用了Message.obtain()).
  3. 设置Message的what值
    Message.what是我们自定义的一个Message的识别码,以便于在Handler的handleMessage方法中根据what识别出不同的Message,以便我们做出不同的处理操作.
  4. 设置Message的所携带的数据,简单数据可以通过两个int类型的field arg1和arg2来赋值,并可以在handleMessage中读取.
  5. 如果Message需要携带复杂的数据,那么可以设置Message的obj字段,obj是Object类型,可以赋予任意类型的数据.或者可以通过调用Message的setData方法赋值Bundle类型的数据,可以通过getData方法获取该Bundle数据.
  6. 我们通过Handler.sendMessage(Message)方法将Message传入Handler中让其在handleMessage中对其进行处理.
    需要说明的是,如果在handleMessage中 不需要判断Message类型,那么就无须设置Message的what值;而且让Message携带数据也不是必须的,只有在需要的时候才需要让其携带数据;如果确实需要让Message携带数据,应该尽量使用arg1或arg2或两者,能用arg1和arg2解决的话就不要用obj,因为用arg1和arg2更高效.

Android内存优化是性能优化很重要的一部分,而如何避免OOM又是内存优化的核心。

Android内存管理机制

android官网有一篇文章

Android是如何管理应用的进程与内存分配
Android系统的Dalvik虚拟机扮演了内存垃圾自动回收的角色。

OOM介绍(out of memory 内存溢出)

Android和java中都会出现由于不良代码引起的内存泄露,为了使Android应用程序能够快速高效的运行,Android每个应用程序都会有专门Dalvik虚拟机实例来运行,也就是每个程序都在属于自己的进程中运行。
这样,某个应用程序内存泄露仅仅只会使自己进程被kill掉不会影响其他进程(如果是system_process等系统进程出现问题,就会造成系统重启),另一方面,系统为每一个应用程序分配了不同的内存上限,如果超过这个上限被视为内存泄露,从而被kill掉。
Dalvik Heap size因不同设备的RAM不同而有所差异,应用占用内存接近这个阀值,在尝试分配内存就会引起outofmemoryError的错误。

出现OOM有几种情况:

1.加载对象过大

2.相应资源过多,来不及加载。

解决这些问题,有:

1.内存引用上做一些处理,常用的有软引用。

2.内存中加载图片直接在内存中做处理(如边界压缩) 这个Glide\Fresco 图片框架可能封装好了

3.动态回收内存

4.优化Delivk虚拟机的堆内存分配

5.自定义堆内存大小

从Android内存分配的角度来理解OOM的产生和避免

共享内存

Android应用程序的进程都是从Zygote的进程fork出来的。Zygote进程在系统启动并载入通用的framework代码和资源后启动。一个新的应用程序启动,系统就会从Zygote中fork出来一个新的进程,在新的进程中加载并允许应用程序的代码。这使得大多数RAM pages被分配给framework的代码,并且RAM资源能够在应用的所有进程之间共享。

大多数static 数据被mmapped到一个进程中,这样使得同样的数据在进程之间能够共享,而且在需要的时候能paged out.常见static 数据包括Dalvik code ,app resourecs,so 文件等。

大多数情况下,Android通过显示的方式分配共享内存区域(例如ashmem或gralloc)来实现动态RAM区域能够在不同进程之间进行共享的机制。比如,Window Surface在APP和Screen Composition之间使用共享的内存,
Cursor Buffers在Content Provider与Clients之间共享内存。

分配与回收内存

每个进程的Dalvik heap都反应了使用内存的占用范围,(Dalvik Heap Size),他可以根据需要进行增长,但是系统有一个上限。

HeapSize跟实际的物理内存大小是不对等的,PSS(proportional Set Size)记录了应用程序自身占用以及和其他进程共享的内容。

Android不会对heap空闲区域进行做碎片整理。系统仅仅在新的内存分配之前判断Heap的尾端剩余空间是否足够,不够就会触发gc操作,从而腾出更多空闲的内存空间。gc操作(garbage collection)也就是所谓的垃圾回收,Android在适当时候触发gc操作,将一些不再使用的对象回收,在Android高级系统针对Heap空间有一个Generational Heap Memory的模型,最近分配的对象在放在young generation区域,当停留一段时间,这个对象会被移动到old generation中,最后在移动到permanent generation区域中。系统会根据内存中不同的内存数据类型进行gc操作,young generation区域的对象更容易被销毁,而且gc操作的速度比old generation的速度要快,时间更短。
每个generation的内存区域都有固定的大小,随着新的对象陆续被分配到此区域,当这些对象的大小快达到阀门值时,就会触发gc操作。通常情况下,gc操作发生时,所有线程都是暂停的。
如何查看本机heap size:
ActivityManager manager=(Activity)getSystemService(Context.ACTIVITY_SERVICE); int heapsize=manager.getMemoryClass();

应用切换操作

Android系统不会再用户切换应用的时候进行交换内存的操作,而是把不包含Foreground组件的应用进程放到LRUCache中,比如用户启动一个应用,系统会为它创建一个进程,但是当用户离开这个应用,此进程不会背立即销毁而是会放到一个Cache中,当用户切换回来够快速的恢复。

发生OOM的条件

通过不同的内存分配方式对不同的对象(bitmap,etc)进行操作因Android版本差异发生变化。
4.0以上,废除了external的计数器,类似bitmap的分配改到dalvik的Java heap(堆)中申请,只要allocated+新分配的内存>=getMemoryClass()就会发生OOM。(在AS memory monitor查看内存中Dalvik Heap的实时变化)

如何避免OOM

减少OOM的第一步就是要尽量减少新分配出来的对象占用内存的大小,尽量使用更加轻量的对象。解决的途径通常有以下几种:

1.使用更加轻量的数据结构
考虑使用ArrayMap/SpareseArray而不是传统的HashMap等数据结构,Android系统为移动系统设计的容器ArrayMap更加高效,占用内存更少,因为HashMap需要一个额外的实例对象来记录Mapping的操作。而SparesArray高效的避免了key和value的自动装箱,而且避免了装箱后的解箱。

2.避免在Android中使用Enum

3.减少Bitmap对象的内存占用
Bitmap是一个消耗内存的大胖子,减少创建出来的Bitmap的内存占用很重要。一般有两种措施

  • inSampleSize:缩放比例,在把图片载入内存之前,我们需要计算一个合适的缩放比例,避免不必要的大图载入。
  • decode format:解码格式,选择ARGB_8888/RBG_565/ARGB_4444/ALPHA_8,存在很大差异。

4.使用更小的图片
在设计图片资源的时候,我们要考虑图片是否存在可以压缩的空间,是否能使用更小的图片,使用小图在xml加载资源时就不会在初始化视图因为内存不足而发生InflationException,其根本原因就是发生了OOM。

内存对象的重复利用

Android最常用的缓存算法LRU(Least Recently Use)

1.复用系统自带的资源,比如字符串、图片、动画、样式、颜色、简单布局,在应用中直接引用,减少自身负重、apk大小、减少内存的开销、复用性更好。但需要考虑版本差异。

2.Listview和GirdView出现大量重复子组件的视图里面对ConvertView的复用。

3.Bitmap对象的复用

  • 在ListView和GridView等显示大量图片的控件里面需要使用LRU机制缓存Bitmap.

  • 利用inBitmap的高级特性提高Android系统在Bitmap分配和释放执行效率,inBitmap属性可以告知Bitmap解码器使用已经存在的内存区域而不是重新申请一块内存区域存放Bitmap,也就是新解码的Bitmap会使用之前那张bitmap在heap占用的内存区域,即使是上千张图片,也只占用屏幕能放下图片的内存

inBitmap的限制

  • SDK19以后:新申请的BItmap大小必须小于或等于前面赋值过的bitmap的大小

  • 新的Bitmap和原来的解码格式要相同,我们可以创建包含多种类型可以重用的bitmap对象池,这样后序的bitmap创建就可以找到合适的模板去重用。

4.避免在onDraw方法里面执行对象的创建
在onDraw这种频繁调用的方法要避免对象的创建操作,因为他会迅速增加内存的使用,引起频繁的gc,甚至内存抖动

5.StringBuilder
如果代码中有大量字符串拼接操作,使用StringBuilder代替”+”

避免对象的内存泄露

内存对象的泄露会导致不再使用的对象无法及时释放,不仅浪费了宝贵的内存空间,后续要分配内存的时候,空间不足造成OOM。这样,每级的generation会变小,gc更加容易触发,引起内存抖动,带来性能问题。

1.注意Activity的泄露
Activity泄露是内存泄露最为严重的问题,涉及内存多,影响面广

两种情形:

  • 内部类引用导致Activity的泄露
    典型的是Handler导致的Activity泄露,如果Handler中有延迟的任务或者等待执行的任务队列过长,很可能因为Handler继续执行造成Activity的泄露。
    引用链是Looper->MessageQueue->Message->handler->Activity,解决办法是在退出UI之前执行 remove Handler消息队列中的消息与runnable对象。或者使用Static+WeakReference的方式来判断Handler和Activity之间存在引用关系。
  • Activity Context被传递到其他实例中,可能导致自身被引用而发生泄露

2.考虑使用Application Context而不是Activity Context
除必须使用Activity Context的情况(Dialog的context必须是Activity),我们可以使用Application Context来避免Activity泄露

3.注意临时Bitmap的及时回收
大多数情况下,我们对Bitmap对象增加缓存机制,但是有时候部分bitmap需要及时回收。比如我们临时创建的摸个相对大的bitmap对象,变换得到新的bitmap对象后,尽快回收原始的bitmap,及时释放原来的空间。

4.注意监听器的注销
android程序里面register后要及时释放unregister那些监听器,自己手动add的listener,要记得remove这个listener.

5.注意缓存容器的对象泄露
有时候我们为了提高对象的复用性,把某些对象放到缓存容器中,如果这些对象没有及时从容器中清楚,也可能导致内存泄露,

6.注意webview的泄露
Android不同版本对webview产生有很大差异,较为严重的问题是webview的泄露,解决办法:为webview新开一个线程,通过AIDL与主进程通信,根据业务的需要在合适的时机进行销毁,从而达到内存的释放。

7.注意cursor对象是否关闭
我们在对数据库进行操作时,使用完cursor没有及时关闭,cursor的泄露,会对内存管理带来负面影响.

内存使用策略优化

1.谨慎使用large heap
android设备由于软硬件的差异,heap阀值不同,特殊情况下可以在manifest中使用largeheap=true声明一个更大的heap空间,使用getLargeMemoryClass()来获取到这个更大的空间。但是要谨慎使用,因为额外的空间会影响到系统整体的用户体验,并且会使每次gc的运行时间更长。切换任务时性能大打折扣,large heap并不一定能获取到更大的heap.

2.综合考虑设备内存阈值与其他因素设计合适的缓存大小
例如,在设计ListView或者GridView的Bitmap LRU缓存的时候,需要考虑的点有:

应用程序剩下了多少可用的内存空间?

  • 有多少图片会被一次呈现到屏幕上?有多少图片需要事先缓存好以便快速滑动时能够立即显示到屏幕?
  • 设备的屏幕大小与密度是多少? 一个xhdpi的设备会比hdpi需要一个更大的Cache来hold住同样数量的图片。
  • 不同的页面针对Bitmap的设计的尺寸与配置是什么,大概会花费多少内存?
  • 页面图片被访问的频率?是否存在其中的一部分比其他的图片具有更高的访问频繁?如果是,也许你想要保存那些最常访问的到内存中,或者为不同组别的位图(按访问频率分组)设置多个LruCache容器。

3.onLowMemory() 与onTrimMemory()
Android可以在不同的应用当中随意切换。为了让background转到foreground, 每一个background都会占用一定的内存。系统会根据内存的使用情况决定回收部分background的应用内存。background的应用从暂停状态恢复到foreground,比较快,如果从kill状态恢复比较慢。

4.资源文件需要选择合适的文件夹进行存放
我们知道hdpi/xhdpi/xxhdpi等等不同dpi的文件夹下的图片在不同的设备上会经过scale的处理。例如我们只在hdpi的目录下放置了一张100X100的图片,那么根据换算关系,xxhdpi 的手机去引用那张图片就会被拉伸到200X200。需要注意到在这种情况下,内存占用是会显著提高的。对于不希望被拉伸的图片,需要放到assets或者nodpi的目录下。

5.Try catch某些大内存分配的操作
在某些情况下,我们需要事先评估那些可能发生OOM的代码,对于这些可能发生OOM的代码,加入catch机制,可以考虑在catch里面尝试一次降级的内存分配操作。例如decode bitmap的时候,catch到OOM,可以尝试把采样比例再增加一倍之后,再次尝试decode。

6.谨慎使用static对象
因为static的生命周期过长,和应用的进程保持一致,使用不当很可能导致对象泄漏,在Android中应该谨慎使用static对象。

7.特别留意单例对象中不合理的持有
虽然单例模式简单实用,提供了很多便利性,但是因为单例的生命周期和应用保持一致,使用不合理很容易出现持有对象的泄漏。

8.珍惜Services资源
如果你的应用需要在后台使用service,除非它被触发并执行一个任务,否则其他时候Service都应该是停止状态。另外需要注意当这个service完成任务之后因为停止service失败而引起的内存泄漏。 当你启动一个Service,系统会倾向为了保留这个Service而一直保留Service所在的进程。这使得进程的运行代价很高,因为系统没有办法把Service所占用的RAM空间腾出来让给其他组件,另外Service还不能被Paged out。这减少了系统能够存放到LRU缓存当中的进程数量,它会影响应用之间的切换效率,甚至会导致系统内存使用不稳定,从而无法继续保持住所有目前正在运行的service。 建议使用IntentService,它会在处理完交代给它的任务之后尽快结束自己。更多信息,请阅读Running in a Background Service

9.优化布局层次,减少内存消耗
越扁平化的视图布局,占用的内存就越少,效率越高。我们需要尽量保证布局足够扁平化,当使用系统提供的View无法实现足够扁平的时候考虑使用自定义View来达到目的。

10.谨慎使用“抽象”编程
很多时候,开发者会使用抽象类作为”好的编程实践”,因为抽象能够提升代码的灵活性与可维护性。然而,抽象会导致一个显著的额外内存开销:他们需要同等量的代码用于可执行,那些代码会被mapping到内存中,因此如果你的抽象没有显著的提升效率,应该尽量避免他们。

11.使用nano protobufs序列化数据
Protocol buffers是由Google为序列化结构数据而设计的,一种语言无关,平台无关,具有良好的扩展性。类似XML,却比XML更加轻量,快速,简单。如果你需要为你的数据实现序列化与协议化,建议使用nano protobufs。关于更多细节,请参考protobuf readme的”Nano version”章节。

12.谨慎使用依赖注入框架
使用类似Guice或者RoboGuice等框架注入代码,在某种程度上可以简化你的代码。然而,那些注入框架会通过扫描你的代码执行许多初始化的操作,这会导致你的代码需要大量的内存空间来mapping代码,而且mapped pages会长时间的被保留在内存中。除非真的很有必要,建议谨慎使用这种技术。

13.谨慎使用多进程
使用多进程可以把应用中的部分组件运行在单独的进程当中,这样可以扩大应用的内存占用范围,但是这个技术必须谨慎使用,绝大多数应用都不应该贸然使用多进程,一方面是因为使用多进程会使得代码逻辑更加复杂,另外如果使用不当,它可能反而会导致显著增加内存。当你的应用需要运行一个常驻后台的任务,而且这个任务并不轻量,可以考虑使用这个技术。

一个典型的例子是创建一个可以长时间后台播放的Music Player。如果整个应用都运行在一个进程中,当后台播放的时候,前台的那些UI资源也没有办法得到释放。类似这样的应用可以切分成2个进程:一个用来操作UI,另外一个给后台的Service。

14.使用ProGuard来剔除不需要的代码
ProGuard能够通过移除不需要的代码,重命名类,域与方法等等对代码进行压缩,优化与混淆。使用ProGuard可以使得你的代码更加紧凑,这样能够减少mapping代码所需要的内存空间。

15.谨慎使用第三方libraries
很多开源的library代码都不是为移动网络环境而编写的,如果运用在移动设备上,并不一定适合。即使是针对Android而设计的library,也需要特别谨慎,特别是在你不知道引入的library具体做了什么事情的时候。例如,其中一个library使用的是nano protobufs, 而另外一个使用的是micro protobufs。这样一来,在你的应用里面就有2种protobuf的实现方式。这样类似的冲突还可能发生在输出日志,加载图片,缓存等等模块里面。另外不要为了1个或者2个功能而导入整个library,如果没有一个合适的库与你的需求相吻合,你应该考虑自己去实现,而不是导入一个大而全的解决方案。

写在最后:

  • 设计风格很大程度上会影响到程序的内存与性能,相对来说,如果大量使用类似Material Design的风格,不仅安装包可以变小,还可以减少内存的占用,渲染性能与加载性能都会有一定的提升。
  • 内存优化并不就是说程序占用的内存越少就越好,如果因为想要保持更低的内存占用,而频繁触发执行gc操作,在某种程度上反而会导致应用性能整体有所下降,这里需要综合考虑做一定的权衡。
  • Android的内存优化涉及的知识面还有很多:内存管理的细节,垃圾回收的工作原理,如何查找内存泄漏等等都可以展开讲很多。OOM是内存优化当中比较突出的一点,尽量减少OOM的概率对内存优化有着很大的意义。

TCP协议的三次握手和四次分手

在Google Groups的TopLanguage中看到一帖讨论TCP“三次握手”觉得很有意思.贴主提出“TCP建立连接为什么是三次握手?”的问题,在众多回复中,有一条回复写道:“这个问题的本质是, 信道不可靠, 但是通信双发需要就某个问题达成一致. 而要解决这个问题, 无论你在消息中包含什么信息, 三次通信是理论上的最小值. 所以三次握手不是TCP本身的要求, 而是为了满足”在不可靠信道上可靠地传输信息”这一需求所导致的. 请注意这里的本质需求,信道不可靠, 数据传输要可靠. 三次达到了, 那后面你想接着握手也好, 发数据也好, 跟进行可靠信息传输的需求就没关系了. 因此,如果信道是可靠的, 即无论什么时候发出消息, 对方一定能收到, 或者你不关心是否要保证对方收到你的消息, 那就能像UDP那样直接发送消息就可以了.”.这可视为对“三次握手”目的的另一种解答思路.

HTTP连接

HTTP协议即超文本传送协议(Hypertext Transfer Protocol ),是Web联网的基础,也是手机联网常用的协议之一,HTTP协议是建立在TCP协议之上的一种应用.
HTTP连接最显著的特点是客户端发送的每次请求都需要服务器回送响应,在请求结束后,会主动释放连接.从建立连接到关闭连接的过程称为“一次连接”.
1)在HTTP 1.0中,客户端的每次请求都要求建立一次单独的连接,在处理完本次请求后,就自动释放连接.

2)在HTTP 1.1中则可以在一次连接中处理多个请求,并且多个请求可以重叠进行,不需要等待一个请求结束后再发送下一个请求.

由于HTTP在每次请求结束后都会主动释放连接,因此HTTP连接是一种“短连接”,要保持客户端程序的在线状态,需要不断地向服务器发起连接请求.通常 的做法是即时不需要获得任何数据,客户端也保持每隔一段固定的时间向服务器发送一次“保持连接”的请求,服务器在收到该请求后对客户端进行回复,表明知道 客户端“在线”.若服务器长时间无法收到客户端的请求,则认为客户端“下线”,若客户端长时间无法收到服务器的回复,则认为网络已经断开.


SOCKET原理

套接字(socket)概念

套接字(socket)是通信的基石,是支持TCP/IP协议的网络通信的基本操作单元.它是网络通信过程中端点的抽象表示,包含进行网络通信必须的五种信息:连接使用的协议,本地主机的IP地址,本地进程的协议端口,远地主机的IP地址,远地进程的协议端口.
应用层通过传输层进行数据通信时,TCP会遇到同时为多个应用程序进程提供并发服务的问题.多个TCP连接或多个应用程序进程可能需要通过同一个 TCP协议端口传输数据.为了区别不同的应用程序进程和连接,许多计算机操作系统为应用程序与TCP/IP协议交互提供了套接字(Socket)接口.应 用层可以和传输层通过Socket接口,区分来自不同应用程序进程或网络连接的通信,实现数据传输的并发服务.

建立socket连接

建立Socket连接至少需要一对套接字,其中一个运行于客户端,称为ClientSocket ,另一个运行于服务器端,称为ServerSocket .
套接字之间的连接过程分为三个步骤:服务器监听,客户端请求,连接确认.
服务器监听:服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态,等待客户端的连接请求.
客户端请求:指客户端的套接字提出连接请求,要连接的目标是服务器端的套接字.为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求.
连接确认:当服务器端套接字监听到或者说接收到客户端套接字的连接请求时,就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发 给客户端,一旦客户端确认了此描述,双方就正式建立连接.而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求.

SOCKET连接与TCP连接

创建Socket连接时,可以指定使用的传输层协议,Socket可以支持不同的传输层协议(TCP或UDP),当使用TCP协议进行连接时,该Socket连接就是一个TCP连接.

Socket连接与HTTP连接

由于通常情况下Socket连接就是TCP连接,因此Socket连接一旦建立,通信双方即可开始相互发送数据内容,直到双方连接断开.但在实际网 络应用中,客户端到服务器之间的通信往往需要穿越多个中间节点,例如路由器、网关、防火墙等,大部分防火墙默认会关闭长时间处于非活跃状态的连接而导致 Socket 连接断连,因此需要通过轮询告诉网络,该连接处于活跃状态.
而HTTP连接使用的是“请求—响应”的方式,不仅在请求时需要先建立连接,而且需要客户端向服务器发出请求后,服务器端才能回复数据.
很多情况下,需要服务器端主动向客户端推送数据,保持客户端与服务器数据的实时与同步.此时若双方建立的是Socket连接,服务器就可以直接将数 据传送给客户端;若双方建立的是HTTP连接,则服务器需要等到客户端发送一次请求后才能将数据传回给客户端,因此,客户端定时向服务器端发送连接请求, 不仅可以保持在线,同时也是在“询问”服务器是否有新的数据,如果有就将数据传给客户端.TCP(Transmission Control Protocol) 传输控制协议

TCP是主机对主机层的传输控制协议,提供可靠的连接服务,采用三次握手确认建立一个连接:

位码即tcp标志位,有6种标示:

SYN(synchronous建立联机)

ACK(acknowledgement 确认)

PSH(push传送)

FIN(finish结束)

RST(reset重置)

URG(urgent紧急)

Sequence number(顺序号码)

Acknowledge number(确认号码)


TCP是什么?

TCP(Transmission Control Protocol 传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议.

具体的关于TCP是什么,我不打算详细的说了;当你看到这篇文章时,我想你也知道TCP的概念了,想要更深入的了解TCP的工作,我们就继续.它只是一个超级麻烦的协议,而它又是互联网的基础,也是每个程序员必备的基本功.首先来看看OSI的七层模型:

我们需要知道TCP工作在网络OSI的七层模型中的第四层——Transport层,IP在第三层——Network层,ARP在第二层——Data Link层;在第二层上的数据,我们把它叫Frame,在第三层上的数据叫Packet,第四层的数据叫Segment. 同时,我们需要简单的知道,数据从应用层发下来,会在每一层都会加上头部信息,进行封装,然后再发送到数据接收端.这个基本的流程你需要知道,就是每个数据都会经过数据的封装和解封装的过程. 在OSI七层模型中,每一层的作用和对应的协议如下:

TCP是一个协议,那这个协议是如何定义的,它的数据格式是什么样子的呢?要进行更深层次的剖析,就需要了解,甚至是熟记TCP协议中每个字段的含义.


TCP头部

其中 ACK SYN 序号 这三个部分在以下会用到,它们的介绍也在下面

上面就是TCP协议头部的格式,由于它太重要了,是理解其它内容的基础,下面就将每个字段的信息都详细的说明一下.

  • Source Port和Destination Port:分别占用16位,表示源端口号和目的端口号;用于区别主机中的不同进程,而IP地址是用来区分不同的主机的,源端口号和目的端口号配合上IP首部中的源IP地址和目的IP地址就能唯一的确定一个TCP连接;
  • Sequence Number:用来标识从TCP发端向TCP收端发送的数据字节流,它表示在这个报文段中的的第一个数据字节在数据流中的序号;主要用来解决网络报乱序的问题;
  • Acknowledgment Number:32位确认序列号包含发送确认的一端所期望收到的下一个序号,因此,确认序号应当是上次已成功收到数据字节序号加1.不过,只有当标志位中的ACK标志(下面介绍)为1时该确认序列号的字段才有效.主要用来解决不丢包的问题;
  • Offset:给出首部中32 bit字的数目,需要这个值是因为任选字段的长度是可变的.这个字段占4bit(最多能表示15个32bit的的字,即4*15=60个字节的首部长度),因此TCP最多有60字节的首部.然而,没有任选字段,正常的长度是20字节;
  • TCP Flags:TCP首部中有6个标志比特,它们中的多个可同时被设置为1,主要是用于操控TCP的状态机的,依次为URG,ACK,PSH,RST,SYN,FIN.每个标志位的意思如下:

URG:此标志表示TCP包的紧急指针域(后面马上就要说到)有效,用来保证TCP连接不被中断,并且督促中间层设备要尽快处理这些数据;

ACK:此标志表示应答域有效,就是说前面所说的TCP应答号将会包含在TCP数据包中;有两个取值:0和1,为1的时候表示应答域有效,反之为0;

PSH:这个标志位表示Push操作.所谓Push操作就是指在数据包到达接收端以后,立即传送给应用程序,而不是在缓冲区中排队;

RST:这个标志表示连接复位请求.用来复位那些产生错误的连接,也被用来拒绝错误和非法的数据包;

SYN:表示同步序号,用来建立连接.SYN标志位和ACK标志位搭配使用,当连接请求的时候,SYN=1,ACK=0;连接被响应的时候,SYN=1,ACK=1;这个标志的数据包经常被用来进行端口扫描.扫描者发送一个只有SYN的数据包,如果对方主机响应了一个数据包回来 ,就表明这台主机存在这个端口;但是由于这种扫描方式只是进行TCP三次握手的第一次握手,因此这种扫描的成功表示被扫描的机器不很安全,一台安全的主机将会强制要求一个连接严格的进行TCP的三次握手;

FIN: 表示发送端已经达到数据末尾,也就是说双方的数据传送完成,没有数据可以传送了,发送FIN标志位的TCP数据包后,连接将被断开.这个标志的数据包也经常被用于进行端口扫描.

  • Window:窗口大小,也就是有名的滑动窗口,用来进行流量控制;这是一个复杂的问题,这篇博文中并不会进行总结的;

暂时需要的信息有:

ACK : TCP协议规定,只有ACK=1时有效,也规定连接建立后所有发送的报文的ACK必须为1

SYN(SYNchronization) : 在连接建立时用来同步序号.当SYN=1而ACK=0时,表明这是一个连接请求报文.对方若同意建立连接,则应在响应报文中使SYN=1和ACK=1. 因此, SYN置1就表示这是一个连接请求或连接接受报文.

FIN (finis)即完,终结的意思, 用来释放一个连接.当 FIN = 1 时,表明此报文段的发送方的数据已经发送完毕,并要求释放连接.

三次握手的过程

  1. 第一次握手:建立连接.客户端发送连接请求报文段,将SYN位置为1,Sequence Number为x;然后,客户端进入SYN_SEND状态,等待服务器的确认;
  2. 第二次握手:服务器收到客户端的SYN报文段,需要对这个SYN报文段进行确认,设置Acknowledgment Number为x+1(Sequence Number+1);同时,自己自己还要发送SYN请求信息,将SYN位置为1,Sequence Number为y;服务器端将上述所有信息放到一个报文段(即SYN+ACK报文段)中,一并发送给客户端,此时服务器进入SYN_RECV状态;
  3. 第三次握手:客户端收到服务器的SYN+ACK报文段.然后将Acknowledgment Number设置为y+1,向服务器发送ACK报文段,这个报文段发送完毕以后,客户端和服务器端都进入ESTABLISHED状态,完成TCP三次握手.
    完成了三次握手,客户端和服务器端就可以开始传送数据.以上就是TCP三次握手的总体介绍.

四次分手的过程

当客户端和服务器通过三次握手建立了TCP连接以后,当数据传送完毕,肯定是要断开TCP连接的啊.那对于TCP的断开连接,这里就有了神秘的“四次分手”.

  1. 第一次分手:主机1(可以使客户端,也可以是服务器端),设置Sequence Number和Acknowledgment Number,向主机2发送一个FIN报文段;此时,主机1进入FIN_WAIT_1状态;这表示主机1没有数据要发送给主机2了;
  2. 第二次分手:主机2收到了主机1发送的FIN报文段,向主机1回一个ACK报文段,Acknowledgment Number为Sequence Number加1;主机1进入FIN_WAIT_2状态;主机2告诉主机1,我“同意”你的关闭请求;
  3. 第三次分手:主机2向主机1发送FIN报文段,请求关闭连接,同时主机2进入LAST_ACK状态;
  4. 第四次分手:主机1收到主机2发送的FIN报文段,向主机2发送ACK报文段,然后主机1进入TIME_WAIT状态;主机2收到主机1的ACK报文段以后,就关闭连接;此时,主机1等待2MSL后依然没有收到回复,则证明Server端已正常关闭,那好,主机1也可以关闭连接了.

至此,TCP的四次分手就这么愉快的完成了.当你看到这里,你的脑子里会有很多的疑问,很多的不懂,感觉很凌乱;没事,我们继续总结.

为什么要三次握手

在谢希仁著《计算机网络》第四版中讲“三次握手”的目的是“为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误”.在另一部经典的《计算机网络》一书中讲“三次握手”的目的是为了解决“网络中存在延迟的重复分组”的问题.

在谢希仁著《计算机网络》书中同时举了一个例子,如下:

为什么A还要发送一次确认呢?这主要是为了防止已失效的链接请求报文段突然又传到了B,因而产生了错误.

所以”已失效的连接请求报文段”是这样产生的,考虑一种正常情况.A发出连接请求,但因连接请求报文丢失而未收到确认,于是A再重传一次连接请求.后来收到了确认,建立了连接.数据传输完毕后,就释放了连接.A共发送了两个连接请求报文段,其中一个丢失,第二个到达了B.没有”已失效的连接请求报文段”.

现假定出现了一种异常情况,即A发出的第一个连接请求报文段并没有丢失,而是在某个网络结点长时间滞留了,以致延误到连接释放以后的某个时间才到达B.本来这是一个早已失效的报文段.但B收到此失效的连接请求报文段后,就误认为是A又发出一次新的连接请求.于是就向A发出确认报文段,同意建立连接,假定不采用三次握手,那么只要B发出确认,新的连接就建立了.

由于现在A并没有发出建立连接的请求,因此不会理睬B的确认,也不会向B发送数据.但B却以为新的数据连接已经建立了,并一直等待A发来数据.B的许多资源就这样浪费了.

采用三次握手的方法可以防止上述现象的发生.例如在刚才的情况下,A不会向B的确认发出确认.B由于收不到确认,就知道A并没有要求建立连接.

这就很明白了,防止了服务器端的一直等待而浪费资源.

为什么要四次分手

那四次分手又是为何呢?TCP协议是一种面向连接的、可靠的、基于字节流的运输层通信协议.TCP是全双工模式,这就意味着,当主机1发出FIN报文段时,只是表示主机1已经没有数据要发送了,主机1告诉主机2,它的数据已经全部发送完毕了;但是,这个时候主机1还是可以接受来自主机2的数据;当主机2返回ACK报文段时,表示它已经知道主机1没有数据发送了,但是主机2还是可以发送数据到主机1的;当主机2也发送了FIN报文段时,这个时候就表示主机2也没有数据要发送了,就会告诉主机1,我也没有数据要发送了,之后彼此就会愉快的中断这次TCP连接.如果要正确的理解四次分手的原理,就需要了解四次分手过程中的状态变化.

  • FIN_WAIT_1: 这个状态要好好解释一下,其实FIN_WAIT_1和FIN_WAIT_2状态的真正含义都是表示等待对方的FIN报文.而这两种状态的区别是:FIN_WAIT_1状态实际上是当SOCKET在ESTABLISHED状态时,它想主动关闭连接,向对方发送了FIN报文,此时该SOCKET即进入到FIN_WAIT_1状态.而当对方回应ACK报文后,则进入到FIN_WAIT_2状态,当然在实际的正常情况下,无论对方何种情况下,都应该马上回应ACK报文,所以FIN_WAIT_1状态一般是比较难见到的,而FIN_WAIT_2状态还有时常常可以用netstat看到.(主动方)
  • FIN_WAIT_2:上面已经详细解释了这种状态,实际上FIN_WAIT_2状态下的SOCKET,表示半连接,也即有一方要求close连接,但另外还告诉对方,我暂时还有点数据需要传送给你(ACK信息),稍后再关闭连接.(主动方)
  • CLOSE_WAIT:这种状态的含义其实是表示在等待关闭.怎么理解呢?当对方close一个SOCKET后发送FIN报文给自己,你系统毫无疑问地会回应一个ACK报文给对方,此时则进入到CLOSE_WAIT状态.接下来呢,实际上你真正需要考虑的事情是察看你是否还有数据发送给对方,如果没有的话,那么你也就可以 close这个SOCKET,发送FIN报文给对方,也即关闭连接.所以你在CLOSE_WAIT状态下,需要完成的事情是等待你去关闭连接.(被动方)
  • LAST_ACK: 这个状态还是比较容易好理解的,它是被动关闭一方在发送FIN报文后,最后等待对方的ACK报文.当收到ACK报文后,也即可以进入到CLOSED可用状态了.(被动方)
  • TIME_WAIT: 表示收到了对方的FIN报文,并发送出了ACK报文,就等2MSL后即可回到CLOSED可用状态了.如果FINWAIT1状态下,收到了对方同时带FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态.(主动方)
  • CLOSED: 表示连接中断.

实例:

TCP的作用是流量控制,主要是控制数据流的传输.下面以浏览网页为例,根据自身理解来解释一下这个过程.(注:第二个ack属于代码段ack位)

握手过程中传送的包里不包含数据,三次握手完毕后,客户端与服务器才正式开始传送数据.

第一次握手:客户端发送syn包(syn=j)到服务器,并进入SYN_SEND状态,等待服务器确认;

第二次握手:服务器收到syn包,必须确认客户的SYN(ack=j+1),同时自己也发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RECV状态;
第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=k+1),此包发送完毕,客户端和服务器进入ESTABLISHED状态,完成三次握手.
握手过程中传送的包里不包含数据,三次握手完毕后,客户端与服务器才正式开始传送数据.理想状态下,TCP连接一旦建立,在通信双方中的任何一方主 动关闭连接之前,TCP 连接都将被一直保持下去.断开连接时服务器和客户端均可以主动发起断开TCP连接的请求,断开过程需要经过“四次握手”(过程就不细写了,就是服务器和客 户端交互,最终确定断开)

对应的实例

IP 192.168.1.116.3337 > 192.168.1.123.7788: S 3626544836:3626544836
IP 192.168.1.123.7788 > 192.168.1.116.3337: S 1739326486:1739326486 ack 3626544837
IP 192.168.1.116.3337 > 192.168.1.123.7788: ack 1739326487,ack 1

第一次握手:192.168.1.116发送位码syn=1,随机产生seq number=3626544836的数据包到192.168.1.123,192.168.1.123由SYN=1知道192.168.1.116要求建立联机;

第二次握手:192.168.1.123收到请求后要确认联机信息,向192.168.1.116发送ack number=3626544837,syn=1,ack=1,随机产生seq=1739326486的包;

第三次握手:192.168.1.116收到后检查ack number是否正确,即第一次发送的seq number+1,以及位码ack是否为1,若正确,192.168.1.116会再发送ack number=1739326487,ack=1,192.168.1.123收到后确认seq=seq+1,ack=1则连接建立成功.

资料来源:

[1] 通俗大白话来理解TCP协议的三次握手和四次分手

https://github.com/jawil/blog/issues/14

互联网协议入门

物理拓扑分类

1.总线拓扑

总线拓扑结构是将网络中的所有设备通过相应的硬件接口直接连接到公共总线上,结点之间按广播方式通信,一个结点发出的信息,总线上的其它结点均可“收听”到。优点:结构简单、布线容易、可靠性较高,易于扩充,是局域网常采用的拓扑结构。缺点:所有的数据都需经过总线传送,总线成为整个网络的瓶颈;出现故障诊断较为困难。最著名的总线拓扑结构是以太网(Ethernet)。

2.星型拓扑

每个结点都由一条单独的通信线路与中心结点连结。优点:结构简单、容易实现、便于管理,连接点的故障容易监测和排除。缺点:中心结点是全网络的可靠瓶颈,中心结点出现故障会导致网络的瘫痪。

3.环形拓扑

各结点通过通信线路组成闭合回路,环中数据只能单向传输。优点:结构简单、容易实现,适合使用光纤,传输距离远,传输延迟确定。缺点:环网中的每个结点均成为网络可靠性的瓶颈,任意结点出现故障都会造成网络瘫痪,另外故障诊断也较困难。最著名的环形拓扑结构网络是令牌环网(Token Ring)

4.树型拓扑

是一种层次结构,结点按层次连结,信息交换主要在上下结点之间进行,相邻结点或同层结点之间一般不进行数据交换。优点:连结简单,维护方便,适用于汇集信息的应用要求。缺点:资源共享能力较低,可靠性不高,任何一个工作站或链路的故障都会影响整个网络的运行。

5.网状拓扑

又称作无规则结构,结点之间的联结是任意的,没有规律。优点:系统可靠性高,比较容易扩展,但是结构复杂,每一结点都与多点进行连结,因此必须采用路由算法和流量控制方法。目前广域网基本上采用网状拓扑结构。

OSI模型

开放式系统互联通信参考模型(英语:Open System Interconnection Reference Model,缩写为 OSI),简称为OSI模型

层次划分

根据建议X.200,OSI将计算机网络体系结构划分为以下七层,标有1~7,第1层在底部。 现“OSI/RM”是“Open Systems Interconnection Reference Model”的缩写。

第7层 应用层
主条目:应用层

应用层(Application Layer)提供为应用软件而设的接口,以设置与另一应用软件之间的通信。例如: HTTP,HTTPS,FTP,TELNET,SSH,SMTP,POP3等。

第6层 表达层
主条目:表达层

表达层(Presentation Layer)把数据转换为能与接收者的系统格式兼容并适合传输的格式。

第5层 会话层

主条目:会话层

会话层(Session Layer)负责在数据传输中设置和维护计算机网络中两台计算机之间的通信连接。

第4层 传输层

主条目:传输层

传输层(Transport Layer)把传输表头(TH)加至数据以形成数据包。传输表头包含了所使用的协议等发送信息。例如:传输控制协议(TCP)等。

第3层 网络层

主条目:网络层

网络层(Network Layer)决定数据的路径选择和转寄,将网络表头(NH)加至数据包,以形成分组。网络表头包含了网络数据。例如:互联网协议(IP)等。

第2层 数据链路层

主条目:数据链路层

数据链路层(Data Link Layer)负责网络寻址、错误侦测和改错。当表头和表尾被加至数据包时,会形成帧。数据链表头(DLH)是包含了物理地址和错误侦测及改错的方法。数据链表尾(DLT)是一串指示数据包末端的字符串。例如以太网、无线局域网(Wi-Fi)和通用分组无线服务(GPRS)等。

分为两个子层:逻辑链路控制(logic link control,LLC)子层和介质访问控制(media access control,MAC)子层。

第1层 物理层

主条目:物理层

物理层(Physical Layer)在局部局域网上传送数据帧(data frame),它负责管理计算机通信设备和网络媒体之间的互通。包括了针脚、电压、线缆规范、集线器、中继器、网卡、主机适配器等。

数据封装

OSI的核心思想:

1) 通信两端的主机能够理解对方的语言

这是要求主机使用相同协议格式来发送数据

2) 希望通信系统模块化,每个模块提供标准接口

每个模块给和直接耦合的模块提供标准化的流程接口,每个模块内部无论怎样实现没有硬性规定,但外在的接口一定是标准件,这样耦合的模块可以无缝对接起来。

这样做的好处有:
(1) 由于使用标准接口,每个通信模块可以独立开发,增加自由度,提高生产效率
(2) 增加代码的重复利用率,由于通信模块的标准化的外在接口,应用程序可以直接使用现成的通信模块,而无需重新编码,这大大减轻了开发者的负担,间接地提供了生产效率。

OSI参考模型 各层的解释
应用层 为应用程序提供服务
表示层 数据格式转化,数据加密
会话层 建立,管理和维护回话
传输层 建立,管理和维护端到端的连接
网络层 IP选址及路由选择
数据链路层 提供介质访问和链路管理
物理层 物理层

以上七道工序最终产生了二进制流,除了物理层之外,每道工序都会在原始数据前添加一串属于自己的协议头,每个协议头有源主机的每层工序产生,自然有理解这些协议头的对端,举例来说:

链路层协议头,这个协议头只有源主机、端接路由器可以理解,只会存活在源主机与其端接的中继路由器,换句话说,其生命周期只限于一根链路,只要端接路由器能顺利接收,其使命就算完成了,那路由器会将其从二进制流剥离掉,剩下打头阵的就是网络层的协议头。

网络层协议头,其中包含了目的地网络地址,用于指示沿途的路由器,这一串二进制流的目的地是哪里,路由器查询自己的网络地址表,决定再发给更靠近目的地的下一跳路由器。假如找到了一个出接口与下一跳路由器直连,则又要考虑如何添加链路层的协议头了,依据不同的接口类型,添加了适合此链路的链路层协议头,然后再依据物理层介质的不同,物理层将其以光、电、电磁波的信号发送出去。

依照上面类似的步骤,一串二进制流每经过沿途一跳路由器,变换一次链路特有的协议头,但网络层协议头一直不会变,最终这一串二进制流到达了终点。

至此,网络层协议头也完成了其使命,剥离掉网络层协议头,剥离掉之前,先读出网络层协议头的传输层协议代码,把这一串二进制流发给传输层,剩下打头阵的就是传输层的协议头。

传输层协议,依据传输层协议头里会话层协议代码,先剥离掉传输层协议头,把这一串二进制流发给会话层,剩下打头阵的就是会话层协议头。

会话层协议,依据会话层议头里表示层协议代码,先剥离掉会话层协议头,把这一串二进制流发给表示层,剩下的头阵的就是表示层协议头。

表示层协议,依据表示层议头里应用层协议代码,先剥离掉表示层协议头,把这一串二进制流发给应用层,剩下的头阵的就是应用层协议头。

应用层协议,经过以上一跳一跳路由器的中继、终点主机的一层层协议头的剥离,源主机发给终点主机的数据终于到达终点了,至于数据是文字、图片、音频、视频,则由应用层协议来最终解释。

PUD :

PDU: Protocol Data Unit,协议数据单元是指对等层次之间传递的数据单位

物理层的 PDU是数据位 bit

数据链路层的 PDU是数据帧 frame

网络层的PDU是数据包 packet

传输层的 PDU是数据段 segment

其他更高层次的PDU是消息 message

三种通讯模式

单播:一对一

广播:一对多

组播:多对多

网络设备

Hub集线器

Hub:多端口中继器

Hub并不记忆该信息包是由哪个MAC地址发 出,哪个MAC地址在Hub的哪个端口

Hub的特点:

​ 共享带宽

​ 半双工

以太网桥

交换式以太网的优势

• 扩展了网络带宽

• 分割了网络冲突域,使网络冲突被限制在最小的范围内

• 交换机作为更加智能的交换设备,能够提供更多用户所要求的功能:优先级、 虚拟网、远程检测……

以太网桥工作原理

以太网桥监听数据帧中源MAC地址,学习MAC,建立MAC表

对于未知MAC地址,网桥将转发到除接收该帧的端口之外的所有端口

当网桥接到一个数据帧时,如果该帧的目的位于接收端口所在网段上,它就过 滤掉该数据帧;如果目的MAC地址在位于另外一个端口,网桥就将该帧转发到 该端口

当网桥接到广播帧时候,它立即转发到除接收端口之外的所有其他端口

HUB和交换机

集线器属于OSI的第一层物理层设备,而网桥属于OSI的第二层数据链路层设备

从工作方式来看,集线器是一种广播模式,所有端口在一个冲突域里面。网桥 的可以通过端口隔离冲突

Hub是所有共享总线和共享带宽。网桥每个端口占一个带宽

路由器

为了实现路由,路由器需要做下列事情:

分隔广播域

选择路由表中到达目标最好的路径

维护和检查路由信息

连接广域网

路由

把一个数据包从一个设备发送到不同网络里的另一个设备上去。这些工 作依靠路由器来完成。路由器只关心网络的状态和决定网络中的最佳路径。 路由的实现依靠路由器中的路由表来完成

VLAN

• 分隔广播域

• 安全

• 灵活管理

资料来源:

[1] 拓扑

https://baike.baidu.com/item/%E6%8B%93%E6%89%91

[2]OSI模型

https://zh.wikipedia.org/wiki/OSI%E6%A8%A1%E5%9E%8B

[3] 如何生动形象、切中要点地讲解 OSI 七层模型和两主机传输过程? - 车小胖的回答 - 知乎

https://www.zhihu.com/question/24002080/answer/150830722

上一篇文章分析了互联网的总体构思,从下至上,每一层协议的设计思想.

这是从设计者的角度看问题,今天我想切换到用户的角度,看看用户是如何从上至下,与这些协议互动的.


前文小结

先对前面的内容,做一个小结.

我们已经知道,网络通信就是交换数据包.电脑A向电脑B发送一个数据包,后者收到了,回复一个数据包,从而实现两台电脑之间的通信.数据包的结构,基本上是下面这样:

发送这个包,需要知道两个地址:

  • 对方的MAC地址

  • 对方的IP地址

有了这两个地址,数据包才能准确送到接收者手中.但是,前面说过,MAC地址有局限性,如果两台电脑不在同一个子网络,就无法知道对方的MAC地址,必须通过网关(gateway)转发.

上图中,1号电脑要向4号电脑发送一个数据包.它先判断4号电脑是否在同一个子网络,结果发现不是(后文介绍判断方法),于是就把这个数据包发到网关A.网关A通过路由协议,发现4号电脑位于子网络B,又把数据包发给网关B,网关B再转发到4号电脑.

1号电脑把数据包发到网关A,必须知道网关A的MAC地址.所以,数据包的目标地址,实际上分成两种情况:

场景 数据包地址
同一个子网络 对方的MAC地址,对方的IP地址
非同一个子网络 网关的MAC地址,对方的IP地址

发送数据包之前,电脑必须判断对方是否在同一个子网络,然后选择相应的MAC地址.接下来,我们就来看,实际使用中,这个过程是怎么完成的.

用户的上网设置

静态IP地址

你买了一台新电脑,插上网线,开机,这时电脑能够上网吗?

通常你必须做一些设置.有时,管理员(或者ISP)会告诉你下面四个参数,你把它们填入操作系统,计算机就能连上网了:

  • 本机的IP地址   
  • 子网掩码   
  • 网关的IP地址   
  • DNS的IP地址

这四个参数缺一不可,后文会解释为什么需要知道它们才能上网.由于它们是给定的,计算机每次开机,都会分到同样的IP地址,所以这种情况被称作”静态IP地址上网”.

但是,这样的设置很专业,普通用户望而生畏,而且如果一台电脑的IP地址保持不变,其他电脑就不能使用这个地址,不够灵活.出于这两个原因,大多数用户使用”动态IP地址上网”.

动态IP地址

所谓”动态IP地址”,指计算机开机后,会自动分配到一个IP地址,不用人为设定.它使用的协议叫做DHCP协议.

这个协议规定,每一个子网络中,有一台计算机负责管理本网络的所有IP地址,它叫做”DHCP服务器”.新的计算机加入网络,必须向”DHCP服务器”发送一个”DHCP请求”数据包,申请IP地址和相关的网络参数.

前面说过,如果两台计算机在同一个子网络,必须知道对方的MAC地址和IP地址,才能发送数据包.但是,新加入的计算机不知道这两个地址,怎么发送数据包呢?

DHCP协议做了一些巧妙的规定.

DHCP协议

首先,它是一种应用层协议,建立在UDP协议之上,所以整个数据包是这样的:

(1)最前面的”以太网标头”,设置发出方(本机)的MAC地址和接收方(DHCP服务器)的MAC地址.前者就是本机网卡的MAC地址,后者这时不知道,就填入一个广播地址:FF-FF-FF-FF-FF-FF.

(2)后面的”IP标头”,设置发出方的IP地址和接收方的IP地址.这时,对于这两者,本机都不知道.于是,发出方的IP地址就设为0.0.0.0,接收方的IP地址设为255.255.255.255.

(3)最后的”UDP标头”,设置发出方的端口和接收方的端口.这一部分是DHCP协议规定好的,发出方是68端口,接收方是67端口.

这个数据包构造完成后,就可以发出了.以太网是广播发送,同一个子网络的每台计算机都收到了这个包.因为接收方的MAC地址是FF-FF-FF-FF-FF-FF,看不出是发给谁的,所以每台收到这个包的计算机,还必须分析这个包的IP地址,才能确定是不是发给自己的.当看到发出方IP地址是0.0.0.0,接收方是255.255.255.255,于是DHCP服务器知道”这个包是发给我的”,而其他计算机就可以丢弃这个包.

接下来,DHCP服务器读出这个包的数据内容,分配好IP地址,发送回去一个”DHCP响应”数据包.这个响应包的结构也是类似的,以太网标头的MAC地址是双方的网卡地址,IP标头的IP地址是DHCP服务器的IP地址(发出方)和255.255.255.255(接收方),UDP标头的端口是67(发出方)和68(接收方),分配给请求端的IP地址和本网络的具体参数则包含在Data部分.

新加入的计算机收到这个响应包,于是就知道了自己的IP地址、子网掩码、网关地址、DNS服务器等等参数.

上网设置:小结

这个部分,需要记住的就是一点:不管是”静态IP地址”还是”动态IP地址”,电脑上网的首要步骤,是确定四个参数.这四个值很重要,值得重复一遍:

  • 本机的IP地址   
  • 子网掩码   
  • 网关的IP地址   
  • DNS的IP地址

有了这几个数值,电脑就可以上网”冲浪”了.接下来,我们来看一个实例,当用户访问网页的时候,互联网协议是怎么运作的.

一个实例:访问网页

本机参数

我们假定,经过上一节的步骤,用户设置好了自己的网络参数:

  • 本机的IP地址:192.168.1.100
  • 子网掩码:255.255.255.0
  • 网关的IP地址:192.168.1.1
  • DNS的IP地址:8.8.8.8

然后他打开浏览器,想要访问Google,在地址栏输入了网址:www.google.com.

这意味着,浏览器要向google发送一个网页请求的数据包.

DNS协议

我们知道,发送数据包,必须要知道对方的IP地址.但是,现在,我们只知道网址www.google.com,不知道它的IP地址.

DNS协议可以帮助我们,将这个网址转换成IP地址.已知DNS服务器为8.8.8.8,于是我们向这个地址发送一个DNS数据包(53端口).

然后,DNS服务器做出响应,告诉我们google的IP地址是172.194.72.105.于是,我们知道了对方的IP地址.

子网掩码

接下来,我们要判断,这个IP地址是不是在同一个子网络,这就要用到子网掩码.

已知子网掩码是255.255.255.0,本机用它对自己的IP地址192.168.1.100,做一个二进制的AND运算(两个数位都为1,结果为1,否则为0),计算结果为192.168.1.0;然后对Google的IP地址172.194.72.105也做一个AND运算,计算结果为172.194.72.0.这两个结果不相等,所以结论是,Google与本机不在同一个子网络.

因此,我们要向Google发送数据包,必须通过网关192.168.1.1转发,也就是说,接收方的MAC地址将是网关的MAC地址.

应用层协议

浏览网页用的是HTTP协议,它的整个数据包构造是这样的:

HTTP部分的内容,类似于下面这样:

  GET / HTTP/1.1
  Host: www.google.com
  Connection: keep-alive
  User-Agent: Mozilla/5.0 (Windows NT 6.1) ……
  Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8
  Accept-Encoding: gzip,deflate,sdch
  Accept-Language: zh-CN,zh;q=0.8
  Accept-Charset: GBK,utf-8;q=0.7,*;q=0.3
  Cookie: … …

我们假定这个部分的长度为4960字节,它会被嵌在TCP数据包之中.

TCP协议

TCP数据包需要设置端口,接收方(Google)的HTTP端口默认是80,发送方(本机)的端口是一个随机生成的1024-65535之间的整数,假定为51775.

TCP数据包的标头长度为20字节,加上嵌入HTTP的数据包,总长度变为4980字节.

IP协议

然后,TCP数据包再嵌入IP数据包.IP数据包需要设置双方的IP地址,这是已知的,发送方是192.168.1.100(本机),接收方是172.194.72.105(Google).

IP数据包的标头长度为20字节,加上嵌入的TCP数据包,总长度变为5000字节.

以太网协议

最后,IP数据包嵌入以太网数据包.以太网数据包需要设置双方的MAC地址,发送方为本机的网卡MAC地址,接收方为网关192.168.1.1的MAC地址(通过ARP协议得到).

以太网数据包的数据部分,最大长度为1500字节,而现在的IP数据包长度为5000字节.因此,IP数据包必须分割成四个包.因为每个包都有自己的IP标头(20字节),所以四个包的IP数据包的长度分别为1500、1500、1500、560.

服务器端响应

经过多个网关的转发,Google的服务器172.194.72.105,收到了这四个以太网数据包.

根据IP标头的序号,Google将四个包拼起来,取出完整的TCP数据包,然后读出里面的”HTTP请求”,接着做出”HTTP响应”,再用TCP协议发回来.

本机收到HTTP响应以后,就可以将网页显示出来,完成一次网络通信.

资料参考:
[1] 互联网协议入门

http://www.ruanyifeng.com/blog/2012/06/internet_protocol_suite_part_ii.html

信息化社会中每个人都和网络息息相关,大家都能享受到网络带来的快捷和便利.关于网络连接这其实是一个很复杂的事情,学习了很多资料,看过了很多博客之后终于找到阮老师关于互联网原理的讲解.有种恍然大悟的感觉,我转载了文章,希望可以帮助更多的人了解网络的基本原理.


概述

五层模型

互联网的实现,分成好几层.每一层都有自己的功能,就像建筑物一样,每一层都靠下一层支持.

用户接触到的,只是最上面的一层,根本没有感觉到下面的层.要理解互联网,必须从最下层开始,自下而上理解每一层的功能.

如何分层有不同的模型,有的模型分七层,有的分四层.我觉得,把互联网分成五层,比较容易解释.

如上图所示,最底下的一层叫做”实体层”(Physical Layer),最上面的一层叫做”应用层”(Application Layer),中间的三层(自下而上)分别是”链接层”(Link Layer)、”网络层”(Network Layer)和”传输层”(Transport Layer).越下面的层,越靠近硬件:越上面的层,越靠近用户.

它们叫什么名字,其实并不重要.只需要知道,互联网分成若干层就可以了.

层与协议

每一层都是为了完成一种功能.为了实现这些功能,就需要大家都遵守共同的规则.

大家都遵守的规则,就叫做”协议”(protocol).

互联网的每一层,都定义了很多协议.这些协议的总称,就叫做”互联网协议”(Internet Protocol Suite).它们是互联网的核心,下面介绍每一层的功能,主要就是介绍每一层的主要协议.

实体层

我们从最底下的一层开始.

电脑要组网,第一件事要干什么?当然是先把电脑连起来,可以用光缆、电缆、双绞线、无线电波等方式.

这就叫做”实体层”,它就是把电脑连接起来的物理手段.它主要规定了网络的一些电气特性,作用是负责传送0和1的电信号.

链接层

定义

单纯的0和1没有任何意义,必须规定解读方式:多少个电信号算一组?每个信号位有何意义?

这就是”链接层”的功能,它在”实体层”的上方,确定了0和1的分组方式.

以太网协议

早期的时候,每家公司都有自己的电信号分组方式.逐渐地,一种叫做“以太网”(Ethernet)的协议,占据了主导地位.

以太网规定,一组电信号构成一个数据包,叫做”帧”(Frame).每一帧分成两个部分:标头(Head)和数据(Data).

“标头”包含数据包的一些说明项,比如发送者、接受者、数据类型等等:”数据”则是数据包的具体内容.

“标头”的长度,固定为18字节.”数据”的长度,最短为46字节,最长为1500字节.因此,整个”帧”最短为64字节,最长为1518字节.如果数据很长,就必须分割成多个帧进行发送.

MAC地址

上面提到,以太网数据包的”标头”,包含了发送者和接受者的信息.那么,发送者和接受者是如何标识呢?

以太网规定,连入网络的所有设备,都必须具有”网卡”接口.数据包必须是从一块网卡,传送到另一块网卡.网卡的地址,就是数据包的发送地址和接收地址,这叫做MAC地址.

每块网卡出厂的时候,都有一个全世界独一无二的MAC地址,长度是48个二进制位,通常用12个十六进制数表示.

前6个十六进制数是厂商编号,后6个是该厂商的网卡流水号.有了MAC地址,就可以定位网卡和数据包的路径了.

广播

定义地址只是第一步,后面还有更多的步骤.

首先,一块网卡怎么会知道另一块网卡的MAC地址?

回答是有一种ARP协议,可以解决这个问题.这个留到后面介绍,这里只需要知道,以太网数据包必须知道接收方的MAC地址,然后才能发送.

其次,就算有了MAC地址,系统怎样才能把数据包准确送到接收方?

回答是以太网采用了一种很”原始”的方式,它不是把数据包准确送到接收方,而是向本网络内所有计算机发送,让每台计算机自己判断,是否为接收方.

上图中,1号计算机向2号计算机发送一个数据包,同一个子网络的3号、4号、5号计算机都会收到这个包.它们读取这个包的”标头”,找到接收方的MAC地址,然后与自身的MAC地址相比较,如果两者相同,就接受这个包,做进一步处理,否则就丢弃这个包.这种发送方式就叫做”广播”(broadcasting).

有了数据包的定义、网卡的MAC地址、广播的发送方式,”链接层”就可以在多台计算机之间传送数据了.

网络层

网络层的由来

以太网协议,依靠MAC地址发送数据.理论上,单单依靠MAC地址,上海的网卡就可以找到洛杉矶的网卡了,技术上是可以实现的.

但是,这样做有一个重大的缺点.以太网采用广播方式发送数据包,所有成员人手一”包”,不仅效率低,而且局限在发送者所在的子网络.也就是说,如果两台计算机不在同一个子网络,广播是传不过去的.这种设计是合理的,否则互联网上每一台计算机都会收到所有包,那会引起灾难.

互联网是无数子网络共同组成的一个巨型网络,很像想象上海和洛杉矶的电脑会在同一个子网络,这几乎是不可能的.

因此,必须找到一种方法,能够区分哪些MAC地址属于同一个子网络,哪些不是.如果是同一个子网络,就采用广播方式发送,否则就采用”路由”方式发送.(“路由”的意思,就是指如何向不同的子网络分发数据包,这是一个很大的主题,本文不涉及.)遗憾的是,MAC地址本身无法做到这一点.它只与厂商有关,与所处网络无关.

这就导致了”网络层”的诞生.它的作用是引进一套新的地址,使得我们能够区分不同的计算机是否属于同一个子网络.这套地址就叫做”网络地址”,简称”网址”.

于是,”网络层”出现以后,每台计算机有了两种地址,一种是MAC地址,另一种是网络地址.两种地址之间没有任何联系,MAC地址是绑定在网卡上的,网络地址则是管理员分配的,它们只是随机组合在一起.

网络地址帮助我们确定计算机所在的子网络,MAC地址则将数据包送到该子网络中的目标网卡.因此,从逻辑上可以推断,必定是先处理网络地址,然后再处理MAC地址.

IP协议

规定网络地址的协议,叫做IP协议.它所定义的地址,就被称为IP地址.

目前,广泛采用的是IP协议第四版,简称IPv4.这个版本规定,网络地址由32个二进制位组成.

习惯上,我们用分成四段的十进制数表示IP地址,从0.0.0.0一直到255.255.255.255.

互联网上的每一台计算机,都会分配到一个IP地址.这个地址分成两个部分,前一部分代表网络,后一部分代表主机.比如,IP地址172.16.254.1,这是一个32位的地址,假定它的网络部分是前24位(172.16.254),那么主机部分就是后8位(最后的那个1).处于同一个子网络的电脑,它们IP地址的网络部分必定是相同的,也就是说172.16.254.2应该与172.16.254.1处在同一个子网络.

但是,问题在于单单从IP地址,我们无法判断网络部分.还是以172.16.254.1为例,它的网络部分,到底是前24位,还是前16位,甚至前28位,从IP地址上是看不出来的.

那么,怎样才能从IP地址,判断两台计算机是否属于同一个子网络呢?这就要用到另一个参数”子网掩码”(subnet mask).

所谓”子网掩码”,就是表示子网络特征的一个参数.它在形式上等同于IP地址,也是一个32位二进制数字,它的网络部分全部为1,主机部分全部为0.比如,IP地址172.16.254.1,如果已知网络部分是前24位,主机部分是后8位,那么子网络掩码就是11111111.11111111.11111111.00000000,写成十进制就是255.255.255.0.

知道”子网掩码”,我们就能判断,任意两个IP地址是否处在同一个子网络.方法是将两个IP地址与子网掩码分别进行AND运算(两个数位都为1,运算结果为1,否则为0),然后比较结果是否相同,如果是的话,就表明它们在同一个子网络中,否则就不是.

比如,已知IP地址172.16.254.1和172.16.254.233的子网掩码都是255.255.255.0,请问它们是否在同一个子网络?两者与子网掩码分别进行AND运算,结果都是172.16.254.0,因此它们在同一个子网络.

总结一下,IP协议的作用主要有两个,一个是为每一台计算机分配IP地址,另一个是确定哪些地址在同一个子网络.

IP数据包

根据IP协议发送的数据,就叫做IP数据包.不难想象,其中必定包括IP地址信息.

但是前面说过,以太网数据包只包含MAC地址,并没有IP地址的栏位.那么是否需要修改数据定义,再添加一个栏位呢?

回答是不需要,我们可以把IP数据包直接放进以太网数据包的”数据”部分,因此完全不用修改以太网的规格.这就是互联网分层结构的好处:上层的变动完全不涉及下层的结构.

具体来说,IP数据包也分为”标头”和”数据”两个部分.

“标头”部分主要包括版本、长度、IP地址等信息,”数据”部分则是IP数据包的具体内容.它放进以太网数据包后,以太网数据包就变成了下面这样.

IP数据包的”标头”部分的长度为20到60字节,整个数据包的总长度最大为65,535字节.因此,理论上,一个IP数据包的”数据”部分,最长为65,515字节.前面说过,以太网数据包的”数据”部分,最长只有1500字节.因此,如果IP数据包超过了1500字节,它就需要分割成几个以太网数据包,分开发送了.

ARP协议

关于”网络层”,还有最后一点需要说明.

因为IP数据包是放在以太网数据包里发送的,所以我们必须同时知道两个地址,一个是对方的MAC地址,另一个是对方的IP地址.通常情况下,对方的IP地址是已知的(后文会解释),但是我们不知道它的MAC地址.

所以,我们需要一种机制,能够从IP地址得到MAC地址.

这里又可以分成两种情况.第一种情况,如果两台主机不在同一个子网络,那么事实上没有办法得到对方的MAC地址,只能把数据包传送到两个子网络连接处的”网关”(gateway),让网关去处理.

第二种情况,如果两台主机在同一个子网络,那么我们可以用ARP协议,得到对方的MAC地址.ARP协议也是发出一个数据包(包含在以太网数据包中),其中包含它所要查询主机的IP地址,在对方的MAC地址这一栏,填的是FF:FF:FF:FF:FF:FF,表示这是一个”广播”地址.它所在子网络的每一台主机,都会收到这个数据包,从中取出IP地址,与自身的IP地址进行比较.如果两者相同,都做出回复,向对方报告自己的MAC地址,否则就丢弃这个包.

总之,有了ARP协议之后,我们就可以得到同一个子网络内的主机MAC地址,可以把数据包发送到任意一台主机之上了.

传输层

输层的由来

有了MAC地址和IP地址,我们已经可以在互联网上任意两台主机上建立通信.

接下来的问题是,同一台主机上有许多程序都需要用到网络,比如,你一边浏览网页,一边与朋友在线聊天.当一个数据包从互联网上发来的时候,你怎么知道,它是表示网页的内容,还是表示在线聊天的内容?

也就是说,我们还需要一个参数,表示这个数据包到底供哪个程序(进程)使用.这个参数就叫做”端口”(port),它其实是每一个使用网卡的程序的编号.每个数据包都发到主机的特定端口,所以不同的程序就能取到自己所需要的数据.

“端口”是0到65535之间的一个整数,正好16个二进制位.0到1023的端口被系统占用,用户只能选用大于1023的端口.不管是浏览网页还是在线聊天,应用程序会随机选用一个端口,然后与服务器的相应端口联系.

“传输层”的功能,就是建立”端口到端口”的通信.相比之下,”网络层”的功能是建立”主机到主机”的通信.只要确定主机和端口,我们就能实现程序之间的交流.因此,Unix系统就把主机+端口,叫做”套接字”(socket).有了它,就可以进行网络应用程序开发了.

UDP协议

现在,我们必须在数据包中加入端口信息,这就需要新的协议.最简单的实现叫做UDP协议,它的格式几乎就是在数据前面,加上端口号.

UDP数据包,也是由”标头”和”数据”两部分组成.

“标头”部分主要定义了发出端口和接收端口,”数据”部分就是具体的内容.然后,把整个UDP数据包放入IP数据包的”数据”部分,而前面说过,IP数据包又是放在以太网数据包之中的,所以整个以太网数据包现在变成了下面这样:

UDP数据包非常简单,”标头”部分一共只有8个字节,总长度不超过65,535字节,正好放进一个IP数据包.

TCP协议

UDP协议的优点是比较简单,容易实现,但是缺点是可靠性较差,一旦数据包发出,无法知道对方是否收到.

为了解决这个问题,提高网络可靠性,TCP协议就诞生了.这个协议非常复杂,但可以近似认为,它就是有确认机制的UDP协议,每发出一个数据包都要求确认.如果有一个数据包遗失,就收不到确认,发出方就知道有必要重发这个数据包了.

因此,TCP协议能够确保数据不会遗失.它的缺点是过程复杂、实现困难、消耗较多的资源.

TCP数据包和UDP数据包一样,都是内嵌在IP数据包的”数据”部分.TCP数据包没有长度限制,理论上可以无限长,但是为了保证网络的效率,通常TCP数据包的长度不会超过IP数据包的长度,以确保单个TCP数据包不必再分割.

应用层

应用程序收到”传输层”的数据,接下来就要进行解读.由于互联网是开放架构,数据来源五花八门,必须事先规定好格式,否则根本无法解读.

“应用层”的作用,就是规定应用程序的数据格式.

举例来说,TCP协议可以为各种各样的程序传递数据,比如Email、WWW、FTP等等.那么,必须有不同协议规定电子邮件、网页、FTP数据的格式,这些应用程序协议就构成了”应用层”.

这是最高的一层,直接面对用户.它的数据就放在TCP数据包的”数据”部分.因此,现在的以太网的数据包就变成下面这样.

至此,整个互联网的五层结构,自下而上全部讲完了.这是从系统的角度,解释互联网是如何构成的. 下一篇反过来,从用户的角度,自上而下看看这个结构是如何发挥作用,完成一次网络数据交换的.

原文:

[1] 互联网协议入门

http://www.ruanyifeng.com/blog/2012/05/internet_protocol_suite_part_i.html