Java/计算机网络/操作系统面试题总结(未完待续)
2021/7/4 22:22:03
本文主要是介绍Java/计算机网络/操作系统面试题总结(未完待续),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
Java基础
面向对象和面向过程
- **面向过程(Procedure Oriented)**是一种以过程为中心的编程思想。
- 面向过程就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用就可以了。是一种思考问题的基础方法。
- 简单理解:面向过程就是任何事情都亲力亲为,很机械,像个步兵。
- 面向对象(Object Oriented)是一种对现实世界理解和抽象的方法;是思考问题相对高级的方法。
- 面向对象是把构成问题事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙某个事物在整个解决问题的步骤中的行为。
- ⾯向过程性能⽐⾯向对象⾼。 因为类调⽤时需要实例化,开销⽐较⼤,⽐较消耗资源
- ⾯向过程没有⾯向对象易维护、易复⽤、易扩展
字符型常量和字符串常量的区别
- 形式上: 字符常量是单引号引起的⼀个字符; 字符串常量是双引号引起的若⼲个字符
- 含义上: 字符常量相当于⼀个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表⼀个地
址值(该字符串在内存中存放位置) - 占内存⼤⼩ 字符常量只占 2 个字节; 字符串常量占若⼲个字节 (注意: char 在 Java 中占两
个字节)
为什么java是值传递?
按值调用(call by value)表示方法接收的是调用者提供的值,而按引用调用(call by reference)表示方法接收的是调用者提供的变量地址。一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。
基础类型传递:
一个方法不能修改一个基本数据类型的参数(即数值型或布尔型)。
对象传递:
实现一个改变对象参数状态的方法并不是一件难事。理由很简单,方法得到的是对象引用的拷贝,对象引用及其他的拷贝同时引用同一个对象。
引用传递:
一个方法不能让对象参数引用一个新的对象。
方法并没有改变存储在变量 s1 和 s2 中的对象引用。swap 方法的参数 x 和 y 被初始化为两个对象引用的拷贝,这个方法交换的是这两个拷贝
equals和hashcode
hashCode() 的作用是获取哈希码,也称为散列码;它实际上是返回一个 int 整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。
例如在HashMap中,因为我们如果在设计两个对象相等的逻辑时,如果不重写Equals方法,那么一个类有两个对象A1, A2,他们的A1.equals(A2)为true,A1.hashcode和A2.hashcode不一样,当将A1和A2都作为HashMap的key时, HashMap会认为它两不相等,因为HashMap在判断key值相不相等时会判断key的hashcode是不是一样, hashcode一样相等,所以在这种场景下会出现我们认为这两个对象相等,但是hashmap不这么认为,所以会有问题。
hashCode()与 equals()的相关规定:
- 如果两个对象相等,则 hashcode 一定也是相同的
- 两个对象相等,对两个对象分别调用 equals 方法都返回 true
- 两个对象有相同的 hashcode 值,它们也不一定是相等的
- 因此,equals 方法被覆盖过,则 hashCode 方法也必须被覆盖
- hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode(),则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)
多态
来源:https://www.zhihu.com/question/21162041/answer/1268903417
在Java中,方法调用有两类,动态方法调用与静态方法调用。
- 静态方法调用是指对于类的静态方法的调用方式,是在编译时刻就已经确定好具体调用方法的情况,是静态绑定的。
- 动态方法调用需要有方法调用所作用的对象,是在调用的时候才确定具体的调用方法,是动态绑定的。我们这里所讲的多态就是后者—动态方法调用。
多态就是指程序中定义的引⽤变量所指向的具体类型和通过该引⽤变量发出的⽅法调⽤在编译时并不确定,⽽是在程序运⾏期间才确定,即⼀个引⽤变量到底会指向哪个类的实例对象,该引⽤变量发出的⽅法调⽤到底是哪个类中实现的⽅法,必须在由程序运⾏期间才能决定。
多态的具体实现:
(1) 方法重载(类内部之间的多态):就是在类中可以创建多个方法,它们具有相同的名字,但可具有不同的参数列表、返回值类型。
(2)方法重写(父类与子类之间的多态):子类可继承父类中的方法,但有时子类并不想原封不动地继承父类的方法,而是想作一定的修改,这就需要采用方法的重写。重写的参数列表和返回类型均不可修改。
多态分析:
从JVM的角度看:多态方法存在于方法区。java堆存的是就是我们建立的一个个实例对象,而方法区存的就是类的类型信息。而且这个方法区中的类型信息跟在堆中存放的class对象是不同的。在方法区中,这个class的类型信息只有唯一的实例(所以方法区是各个线程共享的内存区域),而在堆中可以有多个该class对象。也就是说方法区的类型信息就是像一个模板,那些class对象就好比通过这些模板创建的一个个实例。
整体流程:
第一步:虚拟机通过reference查询java栈中的本地变量表,得到堆中的对象类型数据的指针,
第二步:通过到对象的指针找到方法区中的对象类型数据
第三步:查询方法表定位到实际类的方法运行。
方法表存在于方法区中的,它是实现多态的关键所在,这里面保存的就是实例方法的引用,而且是直接引用。java虚拟机在执行程序的时候就是通过这个方法表来确定运行哪一个多态方法的。
当子类方法重写父类方法时,新的数据会覆盖原有的数据,也就是说原来指向父类的那个引用会被替换成指向子类的引用(占据原来表中的位置)
符号引用和直接引用的区别:
符号引用就是字符串,这个字符串包含足够的信息,以供实际使用时可以找到相应的位置。你比如说某个方法的符号引用,如:“java/io/PrintStream.println:(Ljava/lang/String;)V”。里面有类的信息,方法名,方法参数等信息。当第一次运行时,要根据字符串的内容,到该类的方法表中搜索这个方法。运行一次之后,符号引用会被替换为直接引用,下次就不用搜索了。直接引用就是偏移量,通过偏移量虚拟机可以直接在该类的内存区域中找到方法字节码的起始位置。
封装和继承
封装:
封装把一个对象的属性私有化,同时提供一些可以被外界访问的属性的方法,如果属性不想被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。
为什么需要封装?
封装将不需要对外提供的内容都隐藏起来**,把属性隐藏,提供公共方法对其访问**。好处就是:隐藏实现细节,提供公共的访问方式;提高了代码的复用性;提高了安全性。
继承
继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,**但不能选择性地继承父类。**通过使用继承我们能够非常方便地复用以前的代码。
关于继承如下 3 点请记住:
- 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有。
- 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。
String StringBuffer 和 StringBuilder 的区别是什么?
- String 类中使⽤ final 关键字修饰字符数组来保存字符串, private final char value[] ,所以 **String 对象是不可变的。**也就可以理解为常量,线程安全。
- StringBuilder 与 StringBuffer 都继承⾃ AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使⽤字符数组保存字符串 char[] value 但是没有⽤ final 关键字修饰,所以这两种对象都是可变的
- StringBuffer 对⽅法加了同步锁或者对调⽤的⽅法加了同步锁,所以是线程安全的。
- StringBuilder 并没有对⽅法进⾏加同步锁,所以是⾮线程安全的。
- 每次对 String 类型进⾏改变的时候,都会⽣成⼀个新的 String 对象,然后将指针指向新的 String对象。
- StringBuffer 每次都会对 StringBuffer 对象本身进⾏操作,⽽不是⽣成新的对象并改变对象引⽤。
- StringBuilder 相⽐使⽤ StringBuffer 仅能获得 10%~15% 左右的性能提升(线程切换),但却要冒多线程不安全的⻛险
- 操作少量的数据: 适⽤ String;单线程操作字符串缓冲区下操作⼤量数据: 适⽤ StringBuilder;多线程操作字符串缓冲区下操作⼤量数据: 适⽤ StringBuffer
为什么String要设计为不可变的?
-
便于实现字符串池(String pool)
在Java中,由于会大量的使用String常量,如果每一次声明一个String都创建一个String对象,那将会造成极大的空间资源的浪费。Java提出了String pool的概念,在堆中开辟一块存储空间String pool,当初始化一个String变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用。 -
使多线程安全
在并发场景下,多个线程同时读一个资源,是安全的,不会引发竞争,但对资源进行写操作时是不安全的,不可变对象不能被写,所以保证了多线程的安全。 -
避免安全问题
在网络连接和数据库连接中字符串常常作为参数,例如,网络连接地址URL,文件路径path,反射机制所需要的String参数。其不可变性可以保证连接的安全性。如果字符串是可变的,黑客就有可能改变字符串指向对象的值,那么会引起很严重的安全问题。 -
加快字符串处理速度
由于String是不可变的,保证了hashcode的唯一性,于是在创建对象时其hashcode就可以放心的缓存了,不需要重新计算。这也就是Map喜欢将String作为Key的原因,处理速度要快过其它的键对象。所以HashMap中的键往往都使用String。
反射
JAVA 反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为 java 语言的反射机制。
- 优点: 运行期类型的判断,动态加载类,提高代码灵活度。
- 缺点: 性能瓶颈:反射相当于一系列解释操作,通知 JVM 要做的事情,性能比直接的 java 代码要慢很多。
反射应用场景:
① 我们在使用 JDBC 连接数据库时使用 Class.forName()通过反射加载数据库的驱动程序;②Spring 框架也用到很多反射机制,最经典的就是 xml 的配置模式。Spring 通过 XML 配置模式装载 Bean 的过程:1) 将程序内所有 XML 或 Properties 配置文件加载入内存中;2)Java 类里面解析 xml 或 properties 里面的内容,得到对应实体类的字节码字符串以及相关的属性信息; 3)使用反射机制,根据这个字符串获得某个类的 Class 实例; 4)动态配置实例的属性
自动装箱和拆箱
装箱: 把一个基本数据类型换成其对应的包装类,基本数据类型存在栈中,而包装类存在堆中,所以装箱时会在堆中分配内存空间,创建一个新对象,性能损耗较大。
拆箱: 把一个包装类转换成其对应的基本数据类型,包装类在堆中,基本数据类型在栈中,由于拆箱后返回的值为基本数据类型,存在栈中,性能损耗不大。
如何实现自动装箱/拆箱?
在**装箱时调用Integer.valueOf()方法,在拆箱时调用Integer.intValue()**方法。同理,其他的基本数据类型装箱时都执行 对应包装类名.valueOf(),拆箱时执行 对应包装类名.xxValue() (xx为基本数据类型标识)。
Int类型的自动装箱:
装箱操作就要麻烦一点,会先判断装箱操作的值是否在一个范围之间:如果在这个范围内,就从缓存中返回一个缓存对象(该范围通常为-128~127);若不在这个范围内,则再新建一个对象返回。
public static Integer valueOf(int i) { if (i >= IntegerCache.low && i <= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i); }
抽象类和接口
- 接口的方法默认是 public,所有方法在接口中不能有实现,抽象类可以有非抽象的方法
- 接口中的实例变量默认是 final 类型的,而抽象类中则不一定
- 一个类可以实现多个接口,但最多只能实现一个抽象类
- 一个类实现接口的话要实现接口的所有方法,而抽象类不一定
- 接口不能用 new 实例化,但可以声明,但是必须引用一个实现该接口的对象 从设计层面来说,抽象是对类的抽象,是一种模板设计,接口是行为的抽象,是一种行为的规范。
final
final 在 Java 中是一个保留的关键字,可以声明成员变量、方法、类以及本地变量。一旦你将引用声明作 final,你将不能改变这个引用了,编译器会检查代码,如果试图将变量再次初始化的话,编译器会报编译错误。
**final变量:**凡是对成员变量或者本地变量(在方法中的或者代码块中的变量称为本地变量)声明为 final 的都叫作 final 变量。final 变量是只读的。
final方法: Java 里用 final 修饰符去修饰一个方法的唯一正确用途就是表达:这个方法原本是一个虚方法,现在通过 final 来声明这个方法不允许在派生类中进一步被覆写(override)
final类: 使用 final 来修饰的类叫作 final 类,final类通常功能是完整的,它们不能被继承,Java 中有许多类是 final 的,比如 String, Interger 以及其他包装类。
内存模型中的final :
final 变量,编译器和处理器都要遵守两个重排序规则,保证 final 变量在对其他线程可见之前,能够正确的初始化完成:
- 构造函数内,对一个 final 变量的写入,与随后把这个被构造对象的引用赋值给一个变量,这两个操作之间不可重排序(保证,在对象引用对任意线程可见之前,对象的 final 变量已经正确初始化了)
- 首次读一个包含 final 变量的对象,与随后首次读这个 final 变量,这两个操作之间不可以重排序(保证在读一个对象的 final 变量之前,一定会先读这个对象的引用)
final关键字的好处:
- final 关键字提高了性能,JVM 和 Java 应用都会缓存 final 变量
- final 变量可以安全的在多线程环境下进行共享,而不需要额外的同步开销
异步/同步 堵塞/非堵塞
同步与异步是对应于调用者与被调用者,它们是线程之间的关系,两个线程之间要么是同步的,要么是异步的
- 同步操作时,调用者需要等待被调用者返回结果,才会进行下一步操作
- 而异步则相反,调用者不需要等待被调用者返回调用,即可进行下一步操作,被调用者通常依靠事件、回调等机制来通知调用者结果
- 同步和异步是指:发送方和接收方是否协调步调一致
- 同步通信是指:发送方和接收方通过一定机制,实现收发步调协调。如:发送方发出数据后,等接收方发回响应以后才发下一个数据包的通讯方式
- 异步通信是指:发送方的发送不管接收方的接收状态。 如:发送方发出数据后,不等接收方发回响应,接着发送下个数据包的通讯方式。
阻塞与非阻塞是对同一个线程来说的,在某个时刻,线程要么处于阻塞,要么处于非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态:
- 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
-
同步阻塞方式:
发送方发送请求之后一直等待响应。
接收方处理请求时进行的IO操作如果不能马上等到返回结果,就一直等到返回结果后,才响应发送方,期间不能进行其他工作。 -
同步非阻塞方式:
发送方发送请求之后,一直等待响应。
接受方处理请求时进行的IO操作如果不能马上的得到结果,就立即返回,取做其他事情。
但是由于没有得到请求处理结果,不响应发送方,发送方一直等待。
当IO操作完成以后,将完成状态和结果通知接收方,接收方再响应发送方,发送方才进入下一次请求过程。(实际不应用) -
异步阻塞方式:
发送方向接收方请求后,不等待响应,可以继续其他工作。
接收方处理请求时进行IO操作如果不能马上得到结果,就一直等到返回结果后,才响应发送方,期间不能进行其他操作。 (实际不应用) -
异步非阻塞方式:
发送方向接收方请求后,不等待响应,可以继续其他工作。
接收方处理请求时进行IO操作如果不能马上得到结果,也不等待,而是马上返回去做其他事情。
当IO操作完成以后,将完成状态和结果通知接收方,接收方再响应发送方。(效率最高)
设计模式
1. 单例模式
- **单例模式定义:**单例模式(Singleton Pattern)属于创建型模式,它提供了一种创建对象的最佳方式。**在当前进程中,通过单例模式创建的类有且只有一个实例。**这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。
- 单例有如下几个特点:
- 在Java应用中,单例模式能保证在一个JVM中,该对象只有一个实例存在
- 构造器必须是私有的,外部类无法通过调用构造器方法创建该实例
- 没有公开的set方法,外部类无法调用set方法创建该实例
- 提供一个公开的get方法获取唯一的这个实例
- 那单例模式有什么好处呢?
- 某些类创建比较频繁,对于一些大型的对象,这是一笔很大的系统开销
- 省去了new操作符,降低了系统内存的使用频率,减轻GC压力
- 系统中某些类,如spring里的controller,控制着处理流程,如果该类可以创建多个的话,系统完全乱了
- 避免了对资源的重复占用
- **缺点:**没有接口,不能继承,与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化。
1. 饿汉式:
**是否 Lazy 初始化:**否
**是否多线程安全:**是
**实现难度:**易
**描述:**这种方式比较常用,但容易产生垃圾对象。
**优点:**没有加锁,执行效率会提高。
**缺点:**类加载时就初始化,浪费内存。
它基于 classloader 机制避免了多线程的同步问题,不过,instance 在类装载时就实例化,虽然导致类装载的原因有很多种,在单例模式中大多数都是调用 getInstance 方法, 但是也不能确定有其他的方式(或者其他的静态方法)导致类装载,这时候初始化 instance 显然没有达到 lazy loading 的效果。
public class Singleton { // 创建一个实例对象 private static Singleton instance = new Singleton(); /** * 私有构造方法,防止被实例化 */ private Singleton(){} /** * 静态get方法 */ public static Singleton getInstance(){ return instance; } }
2. 懒汉式:
线程不安全的:
public class Singleton { private static Singleton instance; private Singleton (){} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }
对getInstance方法加锁即为线程安全的,但是,效率很低,99% 情况下不需要同步。
**懒汉和饿汉的对比:**大家可以发现两者的区别基本上就是第一次创作时候的开销问题,以及线程安全问题(线程不安全模式的懒汉)。
那有了这个对比,那他们的场景好理解了,在很多电商场景,如果这个数据是经常访问的热点数据,那我就可以在系统启动的时候使用饿汉模式提前加载(类似缓存的预热)这样哪怕是第一个用户调用都不会存在创建开销,而且调用频繁也不存在内存浪费了。
而懒汉式呢我们可以用在不怎么热的地方,比如那个数据你不确定很长一段时间是不是有人会调用,那就用懒汉,如果你使用了饿汉,但是过了几个月还没人调用,提前加载的类在内存中是有资源浪费的。
3. 双重校验锁模式
public class Singleton { private volatile static Singleton instance = null; private Singleton(){} public static Singleton getInstance(){ //先检查实例是否存在,如果不存在才进入下面的同步块 if(instance == null){ //同步块,线程安全的创建实例 synchronized (Singleton.class) { //再次检查实例是否存在,如果不存在才真正的创建实例 if(instance == null){ instance = new Singleton(); } } } return instance; } }
另外,需要注意 instance 采⽤ volatile 关键字修饰也是很有必要。instance 采⽤ volatile 关键字修饰也是很有必要的, instance = new Singleton();
这段代码其实是分为三步执⾏:
- 为 instance 分配内存空间
- 初始化 instance
- 将 instance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执⾏顺序有可能变成 1i>3i>2。指令重排在单线程环境下不会出现问题,但是多线程环境下会导致⼀个线程获得还没有初始化的实例。例如,线程 T1 执⾏了 1 和3,此时 T2 调⽤ getInstance() 后发现 instance 不为空,因此返回instance ,但此时 uniqueInstance 还未被初始化。使⽤ volatile 可以禁⽌ JVM 的指令重排,保证在多线程环境下也能正常运⾏。
2. 观察者模式
参考:设计模式之禅
观察者模式(Observer Pattern) 也叫做发布订阅模式(Publish/subscribe) ,它是一个在项目中经常使用的模式, 其定义如下:
Define a one-to-many dependency between objects so that when one object changes state,all its dependents are notified and updated automatically.(定义对象间一种一对多的依赖关系, 使得每当一个对象改变状态, 则所有依赖于它的对象都会得到通知并被自动更新)
● Subject被观察者
定义被观察者必须实现的职责, 它必须能够动态地增加、 取消观察者。 它一般是抽象类或者是实现类, 仅仅完成作为被观察者必须实现的职责: 管理观察者并通知观察者。
● Observer观察者
观察者接收到消息后, 即进行update(更新方法) 操作, 对接收到的信息进行处理。
● ConcreteSubject具体的被观察者
定义被观察者自己的业务逻辑, 同时定义对哪些事件进行通知。
● ConcreteObserver具体的观察者
每个观察在接收到消息后的处理反应是不同, 各个观察者有自己的处理逻辑。
被观察者:
public abstract class Subject { //定义一个观察者数组 private Vector<Observer> obsVector = new Vector<Observer>(); //增加一个观察者 public void addObserver(Observer o){ this.obsVector.add(o); } //删除一个观察者 public void delObserver(Observer o){ this.obsVector.remove(o); } //通知所有观察者 public void notifyObservers(){ for(Observer o:this.obsVector){ o.update(); } } } //具体被观察者 public class ConcreteSubject extends Subject { //具体的业务 public void doSomething(){ /* * do something */ super.notifyObservers(); } }
观察者:
public interface Observer { //更新方法 public void update(); } public class ConcreteObserver implements Observer { //实现更新方法 public void update() { System.out.println("接收到信息, 并进行处理! "); } }
观察者模式的使用场景
● 关联行为场景。 需要注意的是, 关联行为是可拆分的, 而不是“组合”关系。
● 事件多级触发场景。
● 跨系统的消息交换场景, 如消息队列的处理机制
集合
ArrayList
- Arraylist 与 LinkedList 区别?
- 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
- 底层数据结构: Arraylist 底层使⽤的是 Object 数组; LinkedList 底层使⽤的是双向链表 数据结构
- 插入删除时间复杂度: ① ArrayList 采⽤数组存储,所以插⼊和删除元素的时间复杂度受元素位置的影响。 ⽐如:执⾏ add(E e) ⽅法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是O(1)。但是如果要在指定位置 i插⼊和删除元素的话( add(int index, E element) )时间复杂度就为 O(n-i)。因为在进⾏上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执⾏向后位/向前移⼀位的操作。 ②LinkedList 采⽤链表存储,所以对于 add(E e) ⽅法的插⼊,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置 i 插⼊和删除元素的话( (add(int index, E element) ) 时间复杂度近似为 o(n)) 因为需要先移动到指定位置再插⼊。
- 查询复杂度: LinkedList 不⽀持⾼效的随机元素访问,时间复杂度为O(n),⽽ ArrayList ⽀持O(1)。
- 内存空间占⽤: ArrayList的空间浪费主要体现在在list列表的结尾会预留⼀定的容量空间,⽽LinkedList的空间花费则体现在它的每⼀个元素都需要消耗⽐ArrayList更多的空间(因为要存放直接后继和直接前驱以及数据)。
-
ArrayList和Vector的区别?
Vector 类的所有⽅法都是同步的(所有方法都添加了Synchronized关键字)。可以由两个线程安全地访问⼀个Vector对象、但是⼀个线程访问Vector的话代码要在同步操作上耗费⼤量的时间。而ArrayList不是线程安全的 -
ArrayList的扩容机制?
add方法:
public void add(int index, E element) { //1. 检查是否错过数组大小/小于0 rangeCheckForAdd(index); //2. 确保容量是否满足条件 ensureCapacityInternal(size + 1); // Increments modCount!! //3. 创建新数组,将所需添加元素插入新数组 System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; }
private void ensureCapacityInternal(int minCapacity) { //如果当前数组为空数组,则取DEFAULT_CAPACITY(10),和minCapacity中最大值作为minCapacity扩容 if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code //如果minCapacity大于数组长度 if (minCapacity - elementData.length > 0) //数组扩容 grow(minCapacity); }
扩容:
private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; //取原容量+原容量的一半之和作为新的容量 int newCapacity = oldCapacity + (oldCapacity >> 1); //取最大容量 if (newCapacity - minCapacity < 0) newCapacity = minCapacity; //如果满足条件,(minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE :MAX_ARRAY_SIZE; if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); // 创建新的数组 elementData = Arrays.copyOf(elementData, newCapacity); }
HashMap
HashMap源码解析见 https://blog.csdn.net/Zeroowt/article/details/113819853
- HashMap 和 Hashtable 的区别
- HashMap 是⾮线程安全的, HashTable 是线程安全的; HashTable 内部的⽅法基本都经过 synchronized 修饰
- 因为线程安全的问题, HashMap 要⽐ HashTable 效率⾼⼀点。
- 对Null key 和Null value的⽀持: HashMap 中, null 可以作为键,这样的键只有⼀个,可以有⼀个或多个键所对应的值为 null。。但是在 HashTable 中 put 进的键值只要有⼀个 null,直接抛出 NullPointerException。
- 初始容量⼤⼩和每次扩充容量⼤⼩的不同 : 创建时如果不指定容量初始值, Hashtable 默认的初始⼤⼩为11,之后每次扩充,容量变为原来的2n+1。 HashMap 默认的初始化⼤⼩为16。之后每次扩充,容量变为原来的2倍。
- HashMap 和 HashSet区别
HashSet 底层就是基于 HashMap 实现的。
- HashMap 存储键值对 ,HashSet 仅存储对象
- HashSet使⽤成员对象来计算hashcode值,对于两个对象来说hashcode可能相同,所以equals()⽅法⽤来判断对象的相等性,HashMap使⽤键(Key)计算Hashcode
- ConcurrentHashMap 和 Hashtable 的区别
- 底层数据结构: JDK1.7的 ConcurrentHashMap 底层采⽤ 分段的数组+链表 实现, JDK1.8 采⽤的数据结构跟HashMap1.8的结构⼀样,数组+链表/红⿊⼆叉树。 Hashtable 和 JDK1.8 之前的HashMap 的底层数据结构类似都是采⽤ 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突⽽存在的;
- 实现线程安全的⽅式(重要): ① 在JDK1.7的时候, ConcurrentHashMap(分段锁) 对整个桶数组进⾏了分割分段(Segment),每⼀把锁只锁容器其中⼀部分数据,多线程访问容器⾥不同数据段的数据,就不会存在锁竞争,提⾼并发访问率。 到了 JDK1.8 的时候已经摒弃了Segment的概念,⽽是直接⽤ Node 数组+链表+红⿊树的数据结构来实现,并发控制使⽤ synchronized 和CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本; ② Hashtable(同⼀把锁) :使⽤ synchronized 来保证线程安全,效率⾮常低下。当⼀个线程访问同步⽅法时,其他线程也访问同步⽅法,可能会进⼊阻塞或轮询状态,如使⽤ put 添加元素,另⼀个线程不能使⽤put 添加元素,也不能使⽤ get,竞争会越来越激烈效率越低。
JDK1.7中,ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。
Segment 实现了 ReentrantLock,所以 Segment 是⼀种可重⼊锁,扮演锁的⻆⾊。 HashEntry ⽤于存储键值对数据。⼀个 ConcurrentHashMap ⾥包含⼀个 Segment 数组。 Segment 的结构和HashMap类似,是⼀种数组和链表结构,⼀个 Segment 包含⼀个 HashEntry 数组,每个 HashEntry 是⼀个链表结构的元素,每个Segment 守护着⼀个HashEntry数组⾥的元素,当对 HashEntry 数组的数据进⾏修改时,必须⾸先获得对应的 Segment的锁。
JDK1.8中,ConcurrentHashMap取消了Segment分段锁,采⽤CAS和synchronized来保证并发安全。数据结构跟
HashMap1.8的结构类似,数组+链表/红⿊⼆叉树。 Java 8在链表⻓度超过⼀定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红⿊树(寻址时间复杂度为O(log(N)))synchronized只锁定当前链表或红⿊⼆叉树的⾸节点,这样只要hash不冲突,就不会产⽣并发,效率⼜提升N倍。
ConcurrentHashMap的put插入方法:
public V put(K key, V value) { return putVal(key, value, false); } /** Implementation for put and putIfAbsent */ final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException(); //1.计算hash值 int hash = spread(key.hashCode()); int binCount = 0; for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; //2.如果没有初始化,则进行初始化 if (tab == null || (n = tab.length) == 0) tab = initTable(); // 3.如果数组中无此节点,则直接插入 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { //通过cas插入节点 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); else { //如果存在Hash冲突,则在synchronized中插入 V oldVal = null; synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0) { binCount = 1; for (Node<K,V> e = f;; ++binCount) { K ek; if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node<K,V> pred = e; if ((e = e.next) == null) { pred.next = new Node<K,V>(hash, key, value, null); break; } } } else if (f instanceof TreeBin) { Node<K,V> p; binCount = 2; if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } } } } if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); if (oldVal != null) return oldVal; break; } } } addCount(1L, binCount); return null; }
网络IO
网络IO
1. BIO
该模型的整体思路是有一个独立的Acceptor线程负责监听客户端的链接,它接收到客户端链接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。这就是典型的一请求一应答的通讯模型。
该模型的最大问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程数和客户端并发访问数呈现1:1的正比关系,由于线程是Java虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统性能将极具下降,随着并发访问量的继续增大,系统会发生线程对栈溢出、创建新线程失败等问题,并最终导致进程宕机或者僵死,不能对外提供服务。
2. NIO
Java NIO 由以下几个核心部分组成:
- Channels
- Buffers
- Selectors
虽然Java NIO 中除此之外还有很多类和组件,但在我看来,Channel,Buffer 和 Selector 构成了核心的API。其它组件,如Pipe和FileLock,只不过是与三个核心组件共同使用的工具类。
- Channel
Java NIO的通道类似流,但又有些不同:
- 既可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的。
- 通道可以异步地读写。
- 通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。
Channel的实现
这些是Java NIO中最重要的通道的实现:
- FileChannel 从文件中读写数据。
- DatagramChannel 能通过UDP读写网络中的数据。
- SocketChannel 能通过TCP读写网络中的数据。
- ServerSocketChannel 可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel。
- Buffer
Java NIO中的Buffer用于和NIO通道进行交互。如你所知,数据是从通道读入缓冲区,从缓冲区写入到通道中的。
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
- Buffer的基本用法
使用Buffer读写数据一般遵循以下四个步骤:
- 写入数据到Buffer
- 调用flip()方法
- 从Buffer中读取数据
- 调用clear()方法或者compact()方法
当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。
- Buffer的capacity,position和limit
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
为了理解Buffer的工作原理,需要熟悉它的三个属性:capacity,position,limit
- Selector
Selector(选择器)是Java NIO中能够检测一到多个NIO通道,并能够知晓通道是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。
- 为什么使用Selector?
仅用单个线程来处理多个Channels的好处是,只需要更少的线程来处理通道。事实上,可以只用一个线程处理所有的通道。对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源(如内存)。因此,使用的线程越少越好。 - Selector的使用:
1 Selector.open()方法创建一个Selector
2 向Selector注册通道:为了将Channel和Selector配合使用,必须将channel注册到selector上。
3 注册监听事件:register()方法的第二个参数。这是一个“interest集合”,意思是在通过Selector监听Channel时对什么事件感兴趣。可以监听四种不同类型的事件:Connect,Accept,Read,Write。通道触发了一个事件意思是该事件已经就绪。
4 通过Selector选择通道:一旦向Selector注册了一或多个通道,就可以调用几个重载的select()方法。这些方法返回你所感兴趣的事件(如连接、接受、读或写)已经准备就绪的那些通道。select()阻塞到至少有一个通道在你注册的事件上就绪了。
Selector selector = Selector.open(); channel.configureBlocking(false); SelectionKey key = channel.register(selector, SelectionKey.OP_READ); while(true) { int readyChannels = selector.select(); if(readyChannels == 0) continue; Set selectedKeys = selector.selectedKeys(); Iterator keyIterator = selectedKeys.iterator(); while(keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if(key.isAcceptable()) { // a connection was accepted by a ServerSocketChannel. } else if (key.isConnectable()) { // a connection was established with a remote server. } else if (key.isReadable()) { // a channel is ready for reading } else if (key.isWritable()) { // a channel is ready for writing } keyIterator.remove(); } }
总结NIO和IO的区别:
- 面向流与面向缓冲
Java NIO和IO之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。 **Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。**此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。 **Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。**但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。 - 阻塞与非阻塞IO
Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。 Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
Java异常处理
在 Java 中,所有的异常都有⼀个共同的祖先 java.lang 包中的 Throwable 类。 Throwable: 有两个重要的⼦类: Exception(异常) 和 Error(错误) ,⼆者都是 Java 异常处理的重要⼦类,各⾃都包含⼤量⼦类。
Error(错误) : 是程序⽆法处理的错误,表示运⾏应⽤程序中较严重问题。⼤多数错误与代码编写者执⾏的操作⽆关,⽽表示代码运⾏时 JVM(Java 虚拟机)出现的问题。这些错误表示故障发⽣于虚拟机⾃身、或者发⽣在虚拟机试图执⾏应⽤时,如 Java 虚拟机运⾏错误(Virtual MachineError)、类定义错误(NoClassDefFoundError)等。
Exception(异常) :是程序本身可以处理的异常。 Exception 类有⼀个重要的⼦类RuntimeException。 RuntimeException 异常由 Java 虚拟机抛出。 NullPointerException(要访问的变量没有引⽤任何对象时,抛出该异常)、 ArithmeticException(算术运算异常,⼀个整数除以 0时,抛出该异常)和ArrayIndexOutOfBoundsException (下标越界异常)。
深拷⻉ vs 浅拷⻉
- 浅拷⻉:对基本数据类型进⾏值传递,对引⽤数据类型进⾏引⽤传递般的拷⻉,此为浅拷⻉。
- 深拷⻉:对基本数据类型进⾏值传递,对引⽤数据类型,创建⼀个新的对象,并复制其内容,此为深拷⻉。
如何实现深拷贝?
通过重写clone方法来实现深拷贝,与通过重写clone方法实现浅拷贝的基本思路一样,只需要为对象图的每一层的每一个对象都实现Cloneable接口并重写clone方法,最后在最顶层的类的重写的clone方法中调用所有的clone方法即可实现深拷贝。简单的说就是:每一层的每个对象都进行浅拷贝=深拷贝。
第二种方式:将对象序列化为字节序列后,默认会将该对象的整个对象图进行序列化,再通过反序列即可完美地实现深拷贝。
动态代理
雪花算法
https://github.com/beyondfengyu/SnowFlake
ID是数据的唯一标识,在互联网企业中,大部分公司使用的都是Mysql,并且因为需要事务支持,所以通常会使用Innodb存储引擎,传统的做法是利用UUID和数据库的自增ID,UUID太长以及无序,所以并不适合在Innodb中来作为主键,自增ID比较合适,
但是 但是随着公司的业务发展,数据量将越来越大,需要对数据进行分表,而分表后,每个表中的数据都会按自己的节奏进行自增,很有可能出现ID冲突。
这时就需要一个单独的机制来负责生成唯一ID,生成出来的ID也可以叫做 分布式ID,或全局ID。
计算机网络
**参考自:**小林coding公众号
了解TCP
什么是TCP?
TCP 是⾯向连接的、可靠的、基于字节流的传输层通信协议
**⾯向连接:**⼀定是「⼀对⼀」才能连接,不能像 UDP 协议可以⼀个主机同时向多个主机发送消息,也就是⼀对多是⽆法做到的;
**可靠的:**⽆论的⽹络链路中出现了怎样的链路变化, TCP 都可以保证⼀个报⽂⼀定能够到达接收端;
**字节流:**消息是「没有边界」的,所以⽆论我们消息有多⼤都可以进⾏传输。并且消息是「有序的」,当「前⼀个」消息没有收到的时候,即使它先收到了后⾯的字节,那么也不能扔给应⽤层去处理,同时对「重复」的报⽂会⾃动丢弃。
TCP报文:
- **序列号:**在建⽴连接时由计算机⽣成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送⼀次数据,就
「累加」⼀次该「数据字节数」的⼤⼩。 ⽤来解决⽹络包乱序问题。 - **确认应答号:**指下⼀次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数
据都已经被正常接收。 ⽤来解决不丢包的问题。 - 控制位:
**ACK:**该位为 1 时,「确认应答」的字段变为有效, TCP 规定除了最初建⽴连接时的 SYN 包之外该位必须设置为 1 。
**RST:**该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。
**SYN:**该位为 1 时,表示希望建⽴连接,并在其「序列号」的字段进⾏序列号初始值的设定。
**FIN:**该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双⽅的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。
为什么需要TCP协议?
IP 层是不可靠的,它不保证⽹络包的交付、不保证⽹络包的按序交付、也不保证⽹络包中的数据的完整性。
如果需要保障⽹络数据包的可靠性,那么就需要由上层(传输层)的 TCP 协议来负责。
因为 TCP 是⼀个⼯作在传输层的可靠数据传输的服务,它能确保接收端接收的⽹络包是⽆损坏、⽆间隔、⾮冗余
和按序的。
OSI网络模型:
- 应⽤层,负责给应⽤程序提供统⼀的接⼝;
- 表示层,负责把数据转换成兼容另⼀个系统能识别的格式;
- 会话层,负责建⽴、管理和终⽌表示层实体之间的通信会话;
- 传输层,负责端到端的数据传输;
- ⽹络层,负责数据的路由、转发、分⽚;
- 数据链路层,负责数据的封帧和差错检测,以及 MAC 寻址;
- 物理层,负责在物理⽹络中传输数据帧;
TCP/IP网络模型:
-
应⽤层,负责向⽤户提供⼀组应⽤程序,⽐如 HTTP、 DNS、 FTP 等;
-
传输层,负责端到端的通信,⽐如 TCP、 UDP 等;
-
⽹络层,负责⽹络包的封装、分⽚、路由、转发,⽐如 IP、 ICMP 等;
-
⽹络接⼝层,负责⽹络包在物理⽹络中的传输,⽐如⽹络包的封帧、 MAC 寻址、差错检测,以及通过⽹卡传输⽹络帧等;
什么是TCP连接?
⽤于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括Socket、序列号和窗⼝⼤⼩称为连接。
IP 层是「不可靠」的,它不保证⽹络包的交付、不保证⽹络包的按序交付、也不保证⽹络包中的数据的完整性。 -
Socket:由 IP 地址和端⼝号组成
-
序列号:⽤来解决乱序问题等
-
窗⼝⼤⼩:⽤来做流量控制
TCP 四元组可以唯⼀的确定⼀个连接,四元组包括如下:源地址、源端⼝、⽬的地址、⽬的端⼝
TCP连接的三次握手
TCP 是⾯向连接的协议,所以使⽤ TCP 前必须先建⽴连接,⽽建⽴连接是通过三次握⼿来进⾏的。
- ⼀开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端⼝,处于 LISTEN 状态
- 客户端会随机初始化序号( client_isn ),将此序号置于 TCP ⾸部的「序号」字段中,同时把 SYN 标志位置为 1 ,表示 SYN 报⽂。接着把第⼀个 SYN 报⽂发送给服务端,表示向服务端发起连接,该报⽂不包含应⽤层数据,之后客户端处于 SYN-SENT 状态。
- 服务端收到客户端的 SYN 报⽂后,⾸先服务端也随机初始化⾃⼰的序号( server_isn ),将此序号填⼊TCP ⾸部的「序号」字段中,其次把 TCP ⾸部的**「确认应答号」字段填⼊ client_isn + 1** , 接着把 SYN和 ACK 标志位置为 1 。最后把该报⽂发给客户端,该报⽂也不包含应⽤层数据,之后服务端处于 SYNRCVD 状态。
- 客户端收到服务端报⽂后,还要向服务端回应最后⼀个应答报⽂,⾸先该应答报⽂ TCP ⾸部 ACK 标志位置为 1 ,其次**「确认应答号」字段填⼊ server_isn + 1** ,最后把报⽂发送给服务端,这次报⽂可以携带客户到服务器的数据,之后客户端处于 ESTABLISHED 状态。
- 服务器收到客户端的应答报⽂后,也进⼊ ESTABLISHED 状态
**注意:**第三次握⼿是可以携带数据的,前两次握⼿是不可以携带数据的,
如何在 Linux 系统中查看 TCP 状态?
TCP 的连接状态查看,在 Linux 可以通过 netstat -napt 命令查看。
** &为什么是三次握⼿?不是两次、四次**
TCP三次连接是为了初始化Socket、序列号和窗⼝⼤⼩信息。
以三个⽅⾯分析三次握⼿的原因:
- 三次握⼿才可以阻⽌重复历史连接的初始化(主要原因)
客户端连续发送多次 SYN 建⽴连接的报⽂,在⽹络拥堵情况下,⼀个「旧 SYN 报⽂」⽐「最新的 SYN 」 报⽂早到达了服务端;那么此时服务端就会回⼀个 SYN + ACK 报⽂给客户端;客户端收到后可以根据⾃身的上下⽂,判断这是⼀个历史连接(序列号过期或超时),那么客户端就会发送RST 报⽂给服务端,表示中⽌这⼀次连接。
如果是两次握⼿连接,就不能判断当前连接是否是历史连接,三次握⼿则可以在客户端(发送⽅)准备发送第三次
报⽂时,客户端因有⾜够的上下⽂来判断当前连接是否是历史连接
- 三次握⼿才可以同步双⽅的初始序列号
TCP 协议的通信双⽅, 都必须维护⼀个「序列号」, 序列号是可靠传输的⼀个关键因素,它的作⽤:
- 接收⽅可以去除重复的数据;
- 接收⽅可以根据数据包的序列号按序接收;
- 可以标识发送出去的数据包中, 哪些是已经被对⽅收到的;
- 三次握⼿才可以避免资源浪费
如果只有「两次握⼿」,当客户端的 SYN 请求连接在⽹络中阻塞,客户端没有接收到 ACK 报⽂,就会重新发送 SYN ,由于没有第三次握⼿,服务器不清楚客户端是否收到了⾃⼰发送的建⽴连接的 ACK 确认信号,所以每收到⼀个 SYN 就只能先主动建⽴⼀个连接,这会造成什么情况呢?
如果客户端的 SYN 阻塞了,重复发送多次 SYN 报⽂,那么服务器在收到请求后就会建⽴多个冗余的⽆效链接,造成不必要的资源浪费。
TCP断开的四次挥手
TCP 断开连接是通过四次挥⼿⽅式。双⽅都可以主动断开连接,断开连接后主机中的资源将被释放
- 客户端打算关闭连接,此时会发送⼀个 TCP ⾸部 FIN 标志位被置为 1 的报⽂,也即 FIN 报⽂,之后客户端进⼊ FIN_WAIT_1 状态。
- 服务端收到该报⽂后,就向客户端发送 ACK 应答报⽂,接着服务端进⼊ CLOSED_WAIT 状态。
- 客户端收到服务端的 ACK 应答报⽂后,之后进⼊ FIN_WAIT_2 状态。
- 等待服务端处理完数据后,也向客户端发送 FIN 报⽂,之后服务端进⼊ LAST_ACK 状态。
- 客户端收到服务端的 FIN 报⽂后,回⼀个 ACK 应答报⽂,之后进⼊ TIME_WAIT 状态
- 服务器收到了 ACK 应答报⽂后,就进⼊了 CLOSED 状态,⾄此服务端已经完成连接的关闭。
- 客户端在经过 2MSL ⼀段时间后,⾃动进⼊ CLOSED 状态,⾄此客户端也完成连接的关闭。
每个⽅向都需要⼀个 FIN 和⼀个 ACK,因此通常被称为四次挥⼿
为什么挥⼿需要四次?
- 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
- 服务器收到客户端的 FIN 报⽂时,先回⼀个 ACK 应答报⽂,⽽服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报⽂给客户端来表示同意现在关闭连接。
从上⾯过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN ⼀般都会分开发送,从⽽⽐三次握⼿导致多了⼀次。
为什么 TIME_WAIT 等待的时间是 2MSL?
MSL 是 Maximum Segment Lifetime, 报⽂最⼤⽣存时间,它是任何报⽂在⽹络上存在的最⻓时间,超过这个时间报⽂将被丢弃。因为 TCP 报⽂基于是 IP 协议的,⽽ IP 头中有⼀个 TTL 字段,是 IP 数据报可以经过的最⼤路由数,每经过⼀个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报⽂通知源主机。
TIME_WAIT 等待 2 倍的 MSL,⽐较合理的解释是: ⽹络中可能存在来⾃发送⽅的数据包,当这些发送⽅的数据包被接收⽅处理后⼜会向对⽅发送响应,所以⼀来⼀回需要等待 2 倍的时间。
⽐如如果被动关闭⽅没有收到断开连接的最后的 ACK 报⽂,就会触发超时重发 Fin 报⽂,另⼀⽅接收到 FIN 后,会重发 ACK 给被动关闭⽅, ⼀来⼀去正好 2 个 MSL。
因此,保证「被动关闭连接」的⼀⽅能被正确的关闭,即保证最后的 ACK 能让被动关闭⽅接收,从⽽帮助其正常关闭,所以需要Time-wait状态
如果已经建⽴了连接,但是客户端突然出现故障了怎么办?
TCP 有⼀个机制是保活机制。这个机制的原理是这样的:
定义⼀个时间段,在这个时间段内,如果没有任何连接相关的活动, TCP 保活机制会开始作⽤,每隔⼀个时间间隔,发送⼀个探测报⽂,该探测报⽂包含的数据⾮常少,如果连续⼏个探测报⽂都没有得到响应,则认为当前的TCP 连接已经死亡,系统内核将错误信息通知给上层应⽤程序
Socket编程
- 服务端和客户端初始化 socket ,得到⽂件描述符;
- 服务端调⽤ bind ,将绑定在 IP 地址和端⼝;
- 服务端调⽤ listen ,进⾏监听;
- 服务端调⽤ accept ,等待客户端连接;
- 客户端调⽤ connect ,向服务器端的地址和端⼝发起连接请求;
- 服务端 accept 返回⽤于传输的 socket 的⽂件描述符;
- 客户端调⽤ write 写⼊数据;服务端调⽤ read 读取数据;
- 客户端断开连接时,会调⽤ close ,那么服务端 read 读取数据的时候,就会读取到了 EOF ,待处理完数据后,服务端调⽤ close ,表示连接关闭。
服务端调⽤ accept 时,连接成功了会返回⼀个已完成连接的 socket,后续⽤来传输数据。所以,监听的 socket 和真正⽤来传送数据的 socket,是「两个」 socket,⼀个叫作监听 socket,⼀个叫作已完成连接 socket
从输入URL到网页呈现的过程
- 浏览器做的第⼀步⼯作是解析 URL
- 浏览器确定了 Web 服务器和⽂件名,接下来就是根据这些信息来⽣成 HTTP 请求消息了
- 通过浏览器解析 URL 并⽣成 HTTP 消息后,需要委托操作系统将消息发送给 Web 服务器
- 在发送之前,需要通过DNS服务器查询服务器域名对应的 IP 地址,因为委托操作系统发送消息时,必须提供通信对象的 IP 地址。
- 通过 DNS 获取到 IP 后,就可以把 HTTP 的传输⼯作交给操作系统中的协议栈。应⽤程序(浏览器)通过调⽤ Socket 库,来委托协议栈⼯作。协议栈的上半部分有两块,分别是负责收发数据的TCP 和 UDP 协议,它们两会接受应⽤层的委托执⾏收发数据的操作。协议栈的下⾯⼀半是⽤ IP 协议控制⽹络包收发操作,在互联⽹上传数据时,数据会被切分成⼀块块的⽹络包,⽽将⽹络包发送给对⽅的操作就是由 IP 负责的。
- ⽹络包只是存放在内存中的⼀串⼆进制数字信息,没有办法直接发送给对⽅。因此,我们需要将数字信息转换为电信号,才能在⽹线上传输,也就是说,这才是真正的数据发送过程。负责执⾏这⼀操作的是⽹卡。
- 交换机的包接收操作: 交换机的设计是将⽹络包原样转发到⽬的地。电信号到达⽹线接⼝,交换机⾥的模块进⾏接收,接下来交换机⾥的模块将电信号转换为数字信号
- ⽹络包经过交换机之后,现在到达了路由器,并在此被转发到下⼀个路由器或⽬标设备。
- **服务端接收数据:**数据包抵达服务器后,服务器会先扒开数据包的 MAC 头部,查看是否和服务器⾃⼰的 MAC 地址符合,符合就将包收起来。接着继续扒开数据包的 IP 头,发现 IP 地址符合,根据 IP 头中协议项,知道⾃⼰上层是 TCP 协议。于是,扒开 TCP 的头,⾥⾯有序列号,需要看⼀看这个序列包是不是我想要的,如果是就放⼊缓存中然后返回⼀个 ACK,如果不是就丢弃。 TCP头部⾥⾯还有端⼝号, HTTP 的服务器正在监听这个端⼝号。 于是,服务器⾃然就知道是 HTTP 进程想要这个包,于是就将包发给 HTTP 进程。服务器的 HTTP 进程看到,原来这个请求是要访问⼀个⻚⾯,于是就把这个⽹⻚封装在 HTTP 响应报⽂⾥。
- HTTP 响应报⽂也需要穿上 TCP、 IP、 MAC 头部,不过这次是源地址是服务器 IP 地址,⽬的地址是客户端 IP 地址。从⽹卡出去,交由交换机转发到出城的路由器,路由器就把响应数据包发到了下⼀个路由器,客户端收到了服务器的响应数据包后以同样的方式解析HTTP响应报文,交给浏览器渲染页面。
- 最后,客户端要离开了,向服务器发起了 TCP 四次挥⼿,⾄此双⽅的连接就断开了。
Cookie&Session
因为http协议是无状态的,也就是说每个客户访问服务器资源时,服务器并不知道该客户端是谁,所以需要会话技术识别客户端的状态。会话技术是帮助服务器记住客户端状态(区分客户端)
从打开一个浏览器访问某个站点,到关闭这个浏览器的整个过程,成为一次会话,会话技术就是记录这次会话中客户端的状态和数据的
**Cookie:**存在客户端本身,相对不安全,减少服务器压力
**Session:**存在服务器端,安全性相对好,但服务器压力大一些
这篇关于Java/计算机网络/操作系统面试题总结(未完待续)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-09-28微服务架构中API版本控制的实践
- 2024-09-28AI给的和自己写的Python代码,都无法改变输入框的内容,替换也不行
- 2024-09-27Sentinel配置限流资料:新手入门教程
- 2024-09-27Sentinel配置限流资料详解
- 2024-09-27Sentinel限流资料:新手入门教程
- 2024-09-26Sentinel限流资料入门详解
- 2024-09-26Springboot框架资料:初学者入门教程
- 2024-09-26Springboot框架资料详解:新手入门教程
- 2024-09-26Springboot企业级开发资料:新手入门指南
- 2024-09-26SpringBoot企业级开发资料新手指南