面试总结--(1)

网上的题目,自己百度总结,自己学习用,如果不对 ,请指出

1.HashMap底层如何实现?

自己回答:

   1.7  数组+链表实现  1.8 数组+链表+红黑树

百度答案:

  1. 1.7 HashMap采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,Entry类实际上
     是一个单向的链表结构,它具有Next指针,可以连接下一个Entry实体。链表过长会影响查询效率
     
  2. 1.8 链表长度大于8的时候,链表会转成红黑树
  
  3. 采用数组+链表的原因:数组是用来确定桶的位置,利用元素的key的hash值对数组长度取模得到
      链表是用来解决hash冲突问题,当出现hash值一样的情形,就在数组上的对应位置形成一条链表
      
  4. 如果桶满了,(超过了0.75 * 数组长度),就需要扩容 加载因子 0.753/4)最大程度避免哈希冲突
  
  5.  hashmap中put过程?
      1.对key的hashCode()做hash运算,计算index;
      2.如果没碰撞直接放到bucket里;Node
      3.如果碰撞导致链表过长(链表长度大于8),就把链表转换成红黑树(JDK1.8中的改动)
      4.如果节点已经存在就替换old value(保证key的唯一性)
      5.如果bucket满了(超过load factor*current capacity),就要resize
      
  6.hashmap中get过程?
      1.对key的hashCode()做hash运算,计算index;
      2.如果在bucket里的第一个节点里直接命中,则直接返回;
      3.如果有冲突,则通过key.equals(k)去查找对应的Entry;
      4.若为树,则在树中通过key.equals(k)查找,O(logn); logn快
      5.若为链表,则在链表中通过key.equals(k)查找,O(n)。
      
  7. 知道jdk1.8中hashmap改了啥么?
     由数组+链表的结构改为数组+链表+红黑树。
     优化了高位运算的hash算法:h^(h>>>16)
     扩容后,元素要么是在原位置,要么是在原位置再移动2次幂的位置,且链表顺序不变。
     因为最后一条的变动,hashmap在1.8中,不会在出现死循环问题。
     
  8.为什么在解决hash冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?
    因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。
    当元素小于8个当时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,
    此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
    因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。
    
  9.健可以为Null值么?
      key为null的时候,hash算法最后的值以0来计算,也就是放在数组的第一个位置。
      
  10.你一般用什么作为HashMap的key?
      一般用Integer、String这种不可变类当HashMap当key,而且String最为常用。
      (1)因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。
          这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。 
          这就是HashMap中的键往往都使用字符串。 
      (2)因为获取对象的时候要用到equals()和hashCode()方法,
          那么键对象正确的重写这两个方法是非常重要的,
          这些类已经很规范的覆写了hashCode()以及equals()方法

Hash一致算法?

自己回答:

   不清楚

百度答案:

   1.对于待存储的海量数据,如何将它们分配到各个机器中去?---数据分片与路由
       当数据量很大时,通过改善单机硬件资源的纵向扩充方式来存储数据变得越来越不适用,
       而通过增加机器数目来获得水平横向扩展的方式则越来越流行。
       因此,就有个问题,如何将这些海量的数据分配到各个机器中?
       数据分布到各个机器存储之后,又如何进行查找?
       这里主要记录一致性Hash算法如何将数据分配到各个机器中去
       
   2.衡量一致性哈希算法好处的四个标准
       ①平衡性。平衡性是指哈希的结果能够尽可能分布到所有的缓冲中去,
           这样可以使得所有的缓冲空间都得到利用。
       
       ②单调性。单调性是指如果已经有一些数据通过哈希分配到了相应的机器上,
           又有新的机器加入到系统中。
           哈希的结果应能够保证原有已分配的内容可以被映射到原有的或者新的机器中去,
           而不会被映射到旧的机器集合中的其他机器上。
           这里再解释一下:就是原有的数据要么还是呆在它所在的机器上不动,
           要么被迁移到新的机器上,而不会迁移到旧的其他机器上。

    ③分散性。
        在分布式环境中,终端有可能看不到所有的缓冲,而是只能看到其中的一部分。
        当终端希望通过哈希过程将内容映射到缓冲上时,由于不同终端所见的缓冲范围有可能不同,
        从而导致哈希的结果不一致,最终的结果是相同的内容被不同的终端映射到不同的缓冲区中。
        这种情况显然是应该避免的,因为它导致相同内容被存储到不同缓冲中去,降低了系统存储的效率。
        分散性的定义就是上述情况发生的严重程度。
        好的哈希算法应能够尽量避免不一致的情况发生,也就是尽量降低分散性。 

    ④负载。
        载问题实际上是从另一个角度看待分散性问题。
        既然不同的终端可能将相同的内容映射到不同的缓冲区中,那么对于一个特定的缓冲区而言,
        也可能被不同的用户映射为不同 的内容。
        与分散性一样,这种情况也是应当避免的,因此好的哈希算法应能够尽量降低缓冲的负荷。
        
    3.一致性哈希的原理
        由于一般的哈希函数返回一个int(32bit)型的hashCode。因此,可以将该哈希函数能够返回的
        hashCode表示成一个范围为0---(2^32)-1 环。
        将机器的标识(如:IP地址)作为哈希函数的Key映射到环上。
        hash(Node1) =Key1,hash(Node2) = Key2
        如同样,数据也通过相同的哈希函数映射到环上。这样,按照顺时针方向,
        数据存放在它所在的顺时针方向上的那个机器上。这就是一致性哈希算法分配数据的方式!

说说HashMap和ConcurrentHashMap的区别?treemap和HashMap的区别?

自己回答:

   hashMap 是线程不安全的,ConcurrentHashMap是线程安全的
   HashMap 是无序的,基于hash表实现,treemap是有序的,基于红黑树

百度答案:

HashMap线程不安全,而Hashtable是线程安全,
但是它使用了synchronized进行方法同步,插入、读取数据都使用了

synchronized,当插入数据的时候不能进行读取(相当于把整个Hashtable都锁住了,全表锁),
当多线程并发的情况下,都要竞争同一把锁,导致效率极其低下。
而在JDK1.5后为了改进Hashtable的痛点,ConcurrentHashMap应运而生。

 ConcurrentHashMap采用CAS和synchronized来保证并发安全,
 数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,
就不会产生并发,效率又提升N倍。

 **锁分段技术**:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,
 当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访
 

image.png

   HashTable:

-   底层数组+链表实现,无论key还是value都**不能为null**,线程**安全**    实现线程安全的方式是在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化
-   初始size为**11**,扩容:newsize = olesize*2+1
-   计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length

   HashMap

-   底层数组+链表实现,可**以存储null键和null值**,线程**不安全**
-   初始size为**16**,扩容:newsize = oldsize*2,size一定为2的n次幂
-   扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入
-   插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,
     就会产生无效扩容)
-   当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀
-   计算index方法:index = hash & (tab.length – 1)


HashMap的初始值还要考虑加载因子:

-    **哈希冲突**:若干Key的哈希值按数组大小取模后,如果落在同一个数组下标上,
将组成一条Entry链,对Key的查找需要遍历Entry链上的每个元素执行equals()比较。
-   **加载因子**:为了降低哈希冲突的概率,默认当HashMap中的键值对达到数组大小的75%时,
即会触发扩容。因此,如果预估容量是100,即需要设定100/0.75=134的数组大小。
-   ***空间换时间**:如果希望加快Key查找的时间,还可以进一步降低加载因子,
加大初始大小,以降低哈希冲突的概率*

HashMap和Hashtable都是用hash算法来决定其元素的存储,
     因此HashMap和Hashtable的hash表包含如下属性:

-   容量(capacity):hash表中桶的数量
-   初始化容量(initial capacity):创建hash表时桶的数量,
     HashMap允许在构造器中指定初始化容量
-   尺寸(size):当前hash表中记录的数量
-   负载因子(load factor):负载因子等于“size/capacity”。
    负载因子为0,表示空的hash表,0.5表示半满的散列表,依此类推。
    轻负载的散列表具有冲突少、适宜插入与查询的特点(但是使用Iterator迭代元素时比较慢)

CAS

一、什么是CAS

CAS,compare and swap的缩写,中文翻译成比较并交换。

我们都知道,在java语言之前,并发就已经广泛存在并在服务器领域得到了大量的应用。所以硬件厂商老早就在芯片中加入了大量直至并发操作的原语,从而在硬件层面提升效率。在intel的CPU中,使用cmpxchg指令。

在Java发展初期,java语言是不能够利用硬件提供的这些便利来提升系统的性能的。而随着java不断的发展,Java本地方法(JNI)的出现,使得java程序越过JVM直接调用本地方法提供了一种便捷的方式,因而java在并发的手段上也多了起来。而在Doug Lea提供的cucurenct包中,CAS理论是它实现整个java包的基石。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该 位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前 值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新 值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。

类似于 CAS 的指令允许算法执行读-修改-写操作,而无需害怕其他线程同时 修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法 可以对该操作重新计算。

二、CAS的目的

利用CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法。其它原子操作都是利用类似的特性完成的。而整个J.U.C都是建立在CAS之上的,因此对于synchronized阻塞算法,J.U.C在性能上有了很大的提升。

三、CAS存在的问题

CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作

  1.  ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

关于ABA问题参考文档: blog.hesey.net/2011/09/res…

2. 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

 

3. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

java的内存分区?

线程私有区
    ◆程序计数器:
    
     C寄存器用于存储向下一条指令的的地址,也是即将要执行的指令地址。 
     程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
     为了能够准确地记录各个线程正在执行的当前字节码指令地址,
     最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,
     从而不会出现相互干扰的情况。
     
    ◆栈:栈是运行时的单位,Java 虚拟机栈,线程私有,生命周期和线程一致。
         描述的是 Java 方法执行的内存模型:
         每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表
         (形参也是局部变量)、操作数栈、动态链接、方法出口等信息。
         每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。

    
    ◆静态域:存放在对象中用static定义的静态成员


线程共享区
   ◆堆:堆是 存储时的单位 ,对于绝大多数应用来说,
       这块区域是 JVM 所管理的**内存中最大的一块**。**线程共享,主要是存放对象实例和数组**
   ◆常量池:运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有
        类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool
       Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放
        到方法区的运行时常量池中
   ◆方法区:已被Java虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等。  
   
   

image.png

案例:

@Data
public class UserInfo {
    //成员变量 存再堆中
    private String userName;
    private int age;
    private String phone;

    public UserInfo(String userName, int age, String phone) {
        this.userName = userName;
        this.age = age;
        this.phone = phone;
    }

    public UserInfo() {

    }

    public static void main(String[] args) {

        //oldName 是局部变量,但是string不是基本类型,栈存放只有 oldName 这个引用
        //引用指向的小明存在方法区的常量池中
        String oldName = "小明";
        //oldAge局部变量,基础类型,引用和值都存在栈中。
        int oldAge = 9;
        //userInfo为对象引用,存在栈中,对象(new UserInfo())存在堆中。
        UserInfo userInfo = new UserInfo();
        //nameCs为(形参也是局部)变量局部变量,但是不是基础类型,所有引用存在栈中,值存在方法区的常量池中
        // 当方法change执行完成后,
        //nameCs就会从栈中消失
        userInfo.getUserInfo("我不是张三");

        UserInfo userInfo2 = new UserInfo("李四", 22, "11256");
        //当main方法执行完之后
        //oldName变量,oldAge,userInfo引用将从栈中消失
        //new UserInfo()将等待垃圾回收器进行回收

        UserInfo userInfo3 = new UserInfo();
        System.out.println(userInfo.hashCode());  //355105
        System.out.println(userInfo3.hashCode()); //355105
        System.out.println(userInfo2.hashCode()); //-1316784243
    }

    public String getUserInfo(String nameCs) {
        return nameCs = "张三";
    }
}
class Program
{
    static void Main()
    {
        Person p1 = new Person { Name = "Andy" };
        Person p2 = p1;

        p1.Name = "Bill";
        p1 = new Person { Name = "Carol"};
    }
}

Person p1 = new Person { Name = "Andy" };

解析:在栈上分配一个变量p1,p1的类型为Person;
在堆上分配一个Person实例,该实例的Name为Andy;将P1 指向Andy实例。

Person p2 = p1;

解析:在栈上分配一个变量p2,p2的类型为Person;将p1 赋值给p2;
这时候p1,p2 都指向Andy实例。

p1.Name = "Bill";

解析:**修改引用的值**。将P1的Name修改为Bill,实际上是修改P1指向的Andy实例,
将Andy实例的Name修改为Bill;由于P1,P2同时指向Andy实例,
此时P1,P2的Name都是Bill,简称Bill实例。

p1 = new Person { Name = "Carol"};

解析:**修改引用**。在堆上分配一个Person实例,该实例的Name为Carol;
将P1指向Carol实例;此时,P1指向Carol实例,而P2仍然指向Bill实例。

java对象的回收方式,回收算法?

GC垃圾回收: jvm按照对象的生命周期,将内存按“代”划分(将堆划分为多个地址池):新生代、老年代和持久代(jdk1.8后移除持久代);

在JVM中程序(PC)计数器、JAVA栈、本地方法栈3个区域随线程而生、随线程而灭,
因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,
因为方法结束或者线程结束时,内存自然就跟随着回收了。而堆和方法区则不一样,
这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

java中新创建的对象会先被放在新生代区域,该区域对象使用频繁,
jvm会在该区域使用不同算法回收一定的短期对象,如果某些对象使用次数达到一定限制后,
那么该对象就会被放入老年代区域,老年代区域要比新生代区域更大一些
(堆内存大部分分配给了老年代区   域),而持久代保存的是类的元数据、常量、类静态变量等。  

常见的回收算法分析:

 1.  标记-清除算法(Mark-Sweep)

       标记-清除算法(Mark-Sweep)是最基础的一种垃圾回收算法,
       它分为 2 部分,先把内存区域中的这些对象进行标记,哪些属于可回收标记出来,
       然后把这些垃圾拎出来清理掉  内存碎片(问题)
       
 2. 复制算法:
  
     复制算法(Copying)是在标记清除算法上演化而来,解决标记清除算法的内存碎片问题。
    它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
    当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
    保证了内存的连续可用,内存分配时也就不用考虑内存碎片等复杂情况,逻辑清晰,运行高效
    (成本太大问题)

3. 标记-整理算法(Mark-Compact)的标记过程仍然与标记清除算法一样,
    但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,
    再清理掉端边界以外的内存区域。
    标记-整理算法一方面在标记-清除算法上做了升级,解决了内存碎片的问题,
    也规避了复制算法只能利用一半内存区域的弊端  (效率低)
    
4. 分代收集算法(Generational Collection)严格来说并不是一种思想或理论,
    而是融合上述 3 种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳。

    对象存活周期的不同将内存划分为几块。
    一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用适当的收集算法。

    在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,
    只需要付出少量存活对象的复制成本就可以完成收集。

    而老年代中因为对象存活率高、没有额外空间对它进行分配担保,
    就必须使用标记-清理或者标记-整理算法来进行回收。
    
    

怎么判断对象是否需要回收?

常见的两种判断的算法: 引用计数算法/ 可达性分析算法(Java使用的这一种)

1.引用计数算法
    是在对象中加入一个计数器,当对象被引用,计数器+1,当引用失效,计数器-1
    这种算法实现简单,效率高,但是有一个严重的问题会导致内存泄漏,
    那就是对象之间循环引用,比如说A对象持有B对象的引用,B对象持有A对象的引用,
    那么AB的计数器值永远>=1,也就是说这两个对象永远不会被回收

2.可达性分析算法
    Java中定义了一些起始点,称为GC Root,当有对象引用它的时候,
    就把对象挂载在它下面,形成一个树状结构,当一个对象处于一个这样的树里时,
    就认为此对象是可达的,反之是不可达,如下图

image.png

CMS和G1了解吗?

区别一: 使用范围不一样

CMS收集器是老年代的收集器,可以配合新生代的Serial和ParNew收集器一起使用
G1收集器收集范围是老年代和新生代。不需要结合其他收集器使用

区别二: STW的时间

CMS收集器以最小的停顿时间为目标的收集器。
G1收集器可预测垃圾回收的停顿时间(建立可预测的停顿时间模型)

区别三: 垃圾碎片

CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片
G1收集器使用的是“标记-整理”算法,进行了空间整合,降低了内存空间碎片。

CMS解决什么问题,说一下回收的过程?

1.总体介绍:

CMS(Concurrent Mark-Sweep)是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。
对于要求[服务器] 响应速度的应用上,这种垃圾回收器非常适合。在启动JVM参数加上
-XX:+UseConcMarkSweepGC ,
这个参数表示对于老年代的回收采用CMSCMS采用的基础算法是:标记—清除。

2.CMS过程:

-   初始标记(STW initial mark)
-   并发标记(Concurrent marking)
-   并发预清理(Concurrent precleaning)
-   重新标记(STW remark)
-   并发清理(Concurrent sweeping)
-   并发重置(Concurrent reset)

初始标记 :在这个阶段,需要虚拟机停顿正在执行的任务,官方的叫法STW(Stop The Word)。这个过程从垃圾回收的"根对象"开始,只扫描到能够和"根对象"直接关联的对象,并作标记。所以这个过程虽然暂停了整个JVM,但是很快就完成了。

并发标记 :这个阶段紧随初始标记阶段,在初始标记的基础上继续向下追溯标记。并发标记阶段,应用程序的线程和并发标记的线程并发执行,所以用户不会感受到停顿。

并发预清理 :并发预清理阶段仍然是并发的。在这个阶段,虚拟机查找在执行并发标记阶段新进入老年代的对象(可能会有一些对象从[新生代]晋升到老年代, 或者有一些对象被分配到老年代)。通过重新扫描,减少下一个阶段"重新标记"的工作,因为下一个阶段会Stop The World。

重新标记 :这个阶段会暂停虚拟机,收集器线程扫描在CMS堆中剩余的对象。扫描从"跟对象"开始向下追溯,并处理对象关联。

并发清理 :清理垃圾对象,这个阶段收集器线程和应用程序线程并发执行。

并发重置 :这个阶段,重置CMS收集器的数据结构,等待下一次垃圾回收。

CMS回收停顿了几次?

CMS并非没有暂停,而是用两次短暂停来替代串行标记整理算法的长暂停

   -   初始标记(STW initial mark)
    -   并发标记(Concurrent marking)
    -   并发预清理(Concurrent precleaning)
    -   重新标记(STW remark)
    -   并发清理(Concurrent sweeping)
    -   并发重置(Concurrent reset)
    
    
    -   初始标记(STW initial mark)    /   -   并发预清理(Concurrent precleaning) 会停顿

java栈什么时候会内存溢出,java堆呢,说一种场景?

栈内存溢出:程序所要求的栈深度过大导致。

堆内存溢出: 分清 内存泄露还是 内存容量不足。泄露则看对象如何被 GC Root 引用。不足则通过 调大 -Xms,-Xmx参数。

持久带内存溢出:Class对象未被释放,Class对象占用信息过多,有过多的Class对象。

无法创建本地线程:总容量不变,堆内存,非堆内存设置过大,会导致能给线程的内存不足

1、栈溢出

首先搞清楚java栈空间存储的是什么。java栈空间是线程私有的,是java方法执行的内存模型。每个方法执行时都会在java栈空间产生一个栈帧,存放方法的变量表,返回值等信息,方法的执行到结束就是一个栈帧入栈到出栈的过程。 所以栈溢出的原因一般是循环调用方法导致栈帧不断增多,栈深度不断增加,最终没有内存可以分配,出现StackOverflowError,比如下面这种情况:

public class stack{
    public void test(){
        this.test();
    }
    public static void main(String[] args){
        for(; ; ;)
            new stack().test;
    }
}

2、堆溢出

java堆是线程共有的区域,主要用来存放对象实例,几乎所有的java对象都在这里分配内存,也是JVM内存管理最大的区域。java堆内存分年轻代和年老代,堆内存溢出一般是年老代溢出。当程序不断地创建大量对象实例并且没有被GC回收时,就容易产生内存溢出。当一个对象产生时,主要过程是这样的:

  • JVM首先在年轻代的Eden区为它分配内存;
  • 若分配成功,则结束,否则JVM会触发一次Young GC,试图释放Eden区的不活跃对象;
  • 如果释放后还没有足够的内存空间,则将Eden区部分活跃对象转移到Suvivor区,Suvivor区长期存活的对象会被转移到老年代;
  • 当老年代空间不够,会触发Full GC,对年老代进行完全的垃圾回收;
  • 回收后如果Suvivor和老年代仍没有充足的空间接收从Eden复制过来的对象,使得Eden区无法为新产生的对象分配内存,即溢出。

由此可见,当程序不断地创建大量对象实例并且没有被GC回收时,就容易产生内存溢出。如下:

public class heap{
    public static void main(String[] args){
        ArrayList list = new ArrayList();
        while(true){
            list.add(new heap());
        }
    }
}

堆内存溢出很可能伴随内存泄漏,应首先排查可能泄露的对象,再通过工具检查GC roots引用链,从而发现泄露对象是由于何种引用关系使得GC无法回收他们;若不存在内存泄漏,换句话说就是内存中的对象还都需要继续存活,则可通过修改虚拟机的堆参数将堆内存增大。

集合类如何解决这个问题(软引用和弱引用),讲下这个两个引用的区别?

就是jvm就像一个国家,gc就是城管,强引用就是当地人,软引用就是移民的人,弱引用就是黑户口,哪天城管逮到就遣走,虚引用就是一个带病的黑户口,指不定哪天自己就挂

1.强引用

以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题

 String str = "abc";
 List<String> list = new Arraylist<String>();
 list.add(str);
 
  在list集合里的数据不会释放,即使内存不足也不会
 

在ArrayList类中定义了一个私有的变量elementData数组,在调用方法清空数组时可以看到为每个数组内容赋值为null。不同于elementData=null,强引用仍然存在,避免在后续调用 add()等方法添加元素时进行重新的内存分配。使用如clear()方法中释放内存的方法对数组中存放的引用类型特别适用,这样就可以及时释放内存。

2、软引用(SoftReference)

如果一个对象只具有软引用,那就类似于可有可物的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。 只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中

软引用在实际中有重要的应用,例如浏览器的后退按钮

按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。

(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建

(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出

这时候就可以使用软引用

3.弱引用(WeakReference)

如果一个对象只具有弱引用,那就类似于可有可物的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。 不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

4.虚引用(PhantomReference)

“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

特别注意,在实际程序设计中一般很少使用弱引用与虚引用,使用软用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

java里的锁了解哪些?

  • 1.公平锁 / 非公平锁
  • 2.可重入锁 / 不可重入锁
  • 3.独享锁 / 共享锁
  • 4.互斥锁 / 读写锁
  • 5.乐观锁 / 悲观锁
  • 6.分段锁
  • 7.偏向锁 / 轻量级锁 / 重量级锁
  • 8.自旋锁

一、公平锁 / 非公平锁

公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。

非公平锁

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。

对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。

对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

二、可重入锁 / 不可重入锁

可重入锁

广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。ReentrantLock和synchronized都是可重入锁

synchronized void setA() throws Exception{
   Thread.sleep(1000);
   setB();
}
synchronized void setB() throws Exception{
   Thread.sleep(1000);
}

上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB可能不会被当前线程执行,可能造成死锁。

不可重入锁

不可重入锁,与可重入锁相反,不可递归调用,递归调用就发生死锁。看到一个经典的讲解,使用自旋锁来模拟一个不可重入锁,代码如下

import java.util.concurrent.atomic.AtomicReference;

public class UnreentrantLock {

 private AtomicReference owner = new AtomicReference();

 public void lock() {
       Thread current = Thread.currentThread();
 //这句是很经典的“自旋”语法,AtomicInteger中也有
 for (;;) {
 if (!owner.compareAndSet(null, current)) {
 return;
           }
       }
   }

 public void unlock() {
       Thread current = Thread.currentThread();
       owner.compareAndSet(current, null);
   }
}

代码也比较简单,使用原子引用来存放线程,同一线程两次调用lock()方法,如果不执行unlock()释放锁的话,第二次调用自旋的时候就会产生死锁,这个锁就不是可重入的,而实际上同一个线程不必每次都去释放锁再来获取锁,这样的调度切换是很耗资源的。

把它变成一个可重入锁:

import java.util.concurrent.atomic.AtomicReference;

public class UnreentrantLock {

 private AtomicReference owner = new AtomicReference();
 private int state = 0;

 public void lock() {
       Thread current = Thread.currentThread();
 if (current == owner.get()) {
           state++;
 return;
       }
 //这句是很经典的“自旋”式语法,AtomicInteger中也有
 for (;;) {
 if (!owner.compareAndSet(null, current)) {
 return;
           }
       }
   }

 public void unlock() {
       Thread current = Thread.currentThread();
 if (current == owner.get()) {
 if (state != 0) {
               state--;
           } else {
               owner.compareAndSet(current, null);
           }
       }
   }
}

在执行每次操作之前,判断当前锁持有者是否是当前对象,采用state计数,不用每次去释放锁。

ReentrantLock中可重入锁实现

这里看非公平锁的锁获取方法:

final boolean nonfairTryAcquire(int acquires) {
 final Thread current = Thread.currentThread();
 int c = getState();
 if (c == 0) {
 if (compareAndSetState(0, acquires)) {
           setExclusiveOwnerThread(current);
 return true;
       }
   }
 //就是这里
 else if (current == getExclusiveOwnerThread()) {
 int nextc = c + acquires;
 if (nextc < 0) // overflow
 throw new Error("Maximum lock count exceeded");
       setState(nextc);
 return true;
   }
 return false;
}

在AQS中维护了一个private volatile int state来计数重入次数,避免了频繁的持有释放操作,这样既提升了效率,又避免了死锁。

三、独享锁 / 共享锁

独享锁和共享锁在你去读C.U.T包下的ReeReentrantLock和ReentrantReadWriteLock你就会发现,它俩一个是独享一个是共享锁。

独享锁: 该锁每一次只能被一个线程所持有。

共享锁: 该锁可被多个线程共有,典型的就是ReentrantReadWriteLock里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占。

另外读锁的共享可保证并发读是非常高效的,但是读写和写写,写读都是互斥的。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享

对于Synchronized而言,当然是独享锁。

四、互斥锁 / 读写锁

互斥锁

在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作。 加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。

如果解锁时有一个以上的线程阻塞,那么所有该锁上的线程都被编程就绪状态, 第一个变为就绪状态的线程又执行加锁操作,那么其他的线程又会进入等待。 在这种方式下,只有一个线程能够访问被互斥锁保护的资源

读写锁

读写锁既是互斥锁,又是共享锁,read模式是共享,write是互斥(排它锁)的。

读写锁有三种状态: 读加锁状态、写加锁状态和不加锁状态

读写锁在Java中的具体实现就是ReadWriteLock

一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。

只有一个线程可以占有写状态的锁,但可以有多个线程同时占有读状态锁,这也是它可以实现高并发的原因。当其处于写状态锁下,任何想要尝试获得锁的线程都会被阻塞,直到写状态锁被释放;如果是处于读状态锁下,允许其它线程获得它的读状态锁,但是不允许获得它的写状态锁,直到所有线程的读状态锁被释放;为了避免想要尝试写操作的线程一直得不到写状态锁,当读写锁感知到有线程想要获得写状态锁时,便会阻塞其后所有想要获得读状态锁的线程。所以读写锁非常适合资源的读操作远多于写操作的情况。

五、乐观锁 / 悲观锁

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

六、分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

并发容器类的加锁机制是基于粒度更小的分段锁,分段锁也是提升多并发程序性能的重要手段之一。

在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。

我们一般有三种方式降低锁的竞争程度:
1、减少锁的持有时间
2、降低锁的请求频率
3、使用带有协调机制的独占锁,这些机制允许更高的并发性。

在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这成为分段锁。

其实说的简单一点就是:

容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

比如:在ConcurrentHashMap中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设使用合理的散列算法使关键字能够均匀的分部,那么这大约能使对锁的请求减少到越来的1/16。也正是这项技术使得ConcurrentHashMap支持多达16个并发的写入线程。

七、偏向锁 / 轻量级锁 / 重量级锁

锁的状态:

  • 1.无锁状态
  • 2.偏向锁状态
  • 3.轻量级锁状态
  • 4.重量级锁状态

锁的状态是通过对象监视器在对象头中的字段来表明的。

四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。

这四种状态都不是Java语言中的锁,而是Jvm为了提高锁的获取与释放效率而做的优化(使用synchronized时)。

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

轻量级

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

八、自旋锁

我们知道CAS算法是乐观锁的一种实现方式,CAS算法中又涉及到自旋锁,所以这里给大家讲一下什么是自旋锁。

简单回顾一下CAS算法

CAS是英文单词Compare and Swap(比较并交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  • 1.需要读写的内存值 V
  • 2.进行比较的值 A
  • 3.拟写入的新值 B

更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B,否则不会执行任何操作。一般情况下是一个自旋操作,即不断的重试。

什么是自旋锁?

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。

Java如何实现自旋锁?

下面是个简单的例子:

public class SpinLock {
 private AtomicReference cas = new AtomicReference();
 public void lock() {
       Thread current = Thread.currentThread();
 // 利用CAS
 while (!cas.compareAndSet(null, current)) {
 // DO nothing
       }
   }
 public void unlock() {
       Thread current = Thread.currentThread();
       cas.compareAndSet(current, null);
   }
}

lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁。

自旋锁存在的问题

  • 1、如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
  • 2、上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。

自旋锁的优点

  • 1、自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
  • 2、非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

可重入的自旋锁和不可重入的自旋锁

文章开始的时候的那段代码,仔细分析一下就可以看出,它是不支持重入的,即当一个线程第一次已经获取到了该锁,在锁释放之前又一次重新获取该锁,第二次就不能成功获取到。由于不满足CAS,所以第二次获取会进入while循环等待,而如果是可重入锁,第二次也是应该能够成功获取到的。

而且,即使第二次能够成功获取,那么当第一次释放锁的时候,第二次获取到的锁也会被释放,而这是不合理的。

为了实现可重入锁,我们需要引入一个计数器,用来记录获取锁的线程数。

public class ReentrantSpinLock {
 private AtomicReference cas = new AtomicReference();
 private int count;
 public void lock() {
       Thread current = Thread.currentThread();
 if (current == cas.get()) { // 如果当前线程已经获取到了锁,线程数增加一,然后返回
           count++;
 return;
       }
 // 如果没获取到锁,则通过CAS自旋
 while (!cas.compareAndSet(null, current)) {
 // DO nothing
       }
   }
 public void unlock() {
       Thread cur = Thread.currentThread();
 if (cur == cas.get()) {
 if (count > 0) {// 如果大于0,表示当前线程多次获取了该锁,释放锁通过count减一来模拟
               count--;
           } else {// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。
               cas.compareAndSet(cur, null);
           }
       }
   }
}

自旋锁与互斥锁

  • 1.自旋锁与互斥锁都是为了实现保护资源共享的机制。
  • 2.无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者。
  • 3获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放。

自旋锁总结

  • 1.自旋锁:线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。
  • 2.自旋锁等待期间,线程的状态不会改变,线程一直是用户态并且是活动的(active)。
  • 3.自旋锁如果持有锁的时间太长,则会导致其它等待获取锁的线程耗尽CPU。
  • 4.自旋锁本身无法保证公平性,同时也无法保证可重入性。
  • 5.基于自旋锁,可以实现具备公平性和可重入性质的锁。

synchronized锁升级的过程(偏向锁到轻量锁再到重量级锁),分别如何实现的,解决的是哪些问题?

Tomcat的基本架构是什么?

Tomcat 是Java语言开发的一个Servlet容器,Servlet和Servlet容器之间的关系就像子弹和枪,互相独立发展又相互依赖。

Tomcat目录结构:

  • /bin - Tomcat 脚本存放目录(如启动、关闭脚本)。
  • /conf - Tomcat 配置文件目录。
  • /logs - Tomcat 默认日志目录。
  • /webapps - webapp 运行的目录。
  • /lib - Tomcat运行需要的库文件。
  • /temp - Tomcat临时文件存放目录
  • /work - Tomcat的工作目录

Tomcat 的总体结构:

  • Server:整个Tomcat服务器,一个Tomcat只有一个Server;
  • Service:Server中的一个逻辑功能层, 一个Server可以包含多个Service;
  • Connector:称作连接器,是Service的核心组件之一,一个Service可以有多个Connector,主要是连接客户端请求;
  • Container:Service的另一个核心组件,按照层级有Engine,Host,Context,Wrapper四种,一个Service只有一个Engine,其主要作用是执行业务逻辑;
  • Jasper:JSP引擎;
  • Session:会话管理;

什么是类加载器?

一、什么是类加载器?

​Java类加载器是Java运行时环境的一部分,负责动态加载Java类到Java虚拟机的内存空间中。类通常是按需加载,即第一次使用该类时才加载。由于有了类加载器,Java运行时系统不需要知道文件与文件系统。学习类加载器时,掌握Java的委派概念很重要。 

二、它是干什么的?

类加载器它是在虚拟机中完成的,负责动态加载Java类到Java虚拟机的内存空间中,在经过 Java 编译器编译之后就被转换成 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码,并转换成 java.lang.Class类的一个实例。

三、类加载器的层次

 

四、类加载器的四个方面

启动类加载器,没有父类。
拓展类加载器由Java语言实现,父类加载器为null
系统类加载器,由Java语言实现
自定义类加载器,父类加载器肯定为AppClassLoader

类加载机制指的是:虚拟机将描述类的数据从class文件加载到内存中,对加载的数据进行验证,解析,初始化,最后得到虚拟机认可后转化为直接可以使用的java类型的过程   类加载机制一共有七个阶段:加载,验证,准备,解析,初始化,使用,卸载。其中的验证,准备,解析合称为连接阶段。   加载,验证,准备,初始化,卸载的顺序是确定是,另两个由动态绑定等情况可能会在初始化后面。

五、对象的创建

image.png

说说双亲委派模型机制?

 类加载器有是三个:启动类加载器、扩展类加载器、应用程序加载器(系统加载器)

image.png   工作过程是:如果一个类加载器收到了一个类加载的请求,它首先不会去加载类,而是去把这个请求委派给父加载器去加载,直到顶层启动类加载器,如果父类加载不了(不在父类加载的搜索范围内),才会自己去加载。

  1. 启动类加载器:加载的是lib目录中的类加载出来,包名是java.xxx(如:java.lang.Object)

  2. 扩展类加载器:加载的是lib/ext目录下的类,包名是javax.xxx(如:javax.swing.xxx)

  3. 应用程序扩展器:这个加载器就是ClassLoader的getSystemClassLoader的返回值,这个也是默认的类加载器。

  双亲委派模型的意义在于不同的类之间分别负责所搜索范围内的类的加载工作,这样能保证同一个类在使用中才不会出现不相等的类,举例:如果出现了两个不同的Object,明明是该相等的业务逻辑就会不相等,应用程序也会变得混乱。

线程池由哪些组件组成?

1、线程池管理器(ThreadPoolManager):用于创建并管理线程池

2、工作线程(WorkThread): 线程池中线程

3、任务接口(Task):每个任务必须实现的接口,以供工作线程调度任务的执行。

4、任务队列:用于存放没有处理的任务。提供一种缓冲机制

有哪些线程池,分别怎么使用?拒绝策略有哪些?

Minor GC (新生代 GC),Full GC (老年代 GC)触发条件

Minor GC触发条件:当Eden区满时,触发Minor GC。

Full GC触发条件:

(1)调用System.gc时,系统建议执行Full GC,但是不必然执行

(2)老年代空间不足

(3)方法区空间不足

(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存

(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

什么时候多线程会发生死锁,写一个例子?

www.cnblogs.com/abcdjava/p/…

两个线程相互得到锁1,锁2,然后线程1等待线程2释放锁2,线程2等待线程1释放锁1,两者各不相互,这样形成死锁。

那么如何避免和解决死锁问题呢?

1、按顺序加锁

上个例子线程间加锁的顺序各不一致,导致死锁,如果每个线程都按同一个的加锁顺序这样就不会出现死锁。

2、获取锁时限

每个获取锁的时候加上个时限,如果超过某个时间就放弃获取锁之类的。

3、死锁检测

按线程间获取锁的关系检测线程间是否发生死锁,如果发生死锁就执行一定的策略,如终断线程或回滚操作等。

Redis的数据结构是什么?线程模型说一下?

速度快,因为数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1)

支持丰富数据类型,支持String,List,Set,Sorted Set,Hash

支持事务,操作都是原子性,所谓的原子性就是对数据的更改要么全部执行,要么全部不执行

丰富的特性:可用于缓存,消息,按key设置过期时间,过期后将会自动删除

string:

 redis 中字符串 value 最多可以是 512M。

场景:做一些计数功能的缓存

list:

list 列表,它是简单的字符串列表,按照插入顺序排序,你可以添加一个元素到列表的头部(左边)或者尾部(右边),它的底层实际上是个链表。

场景:可以当作一个简单消息队列功能,做基于redis的分页功能

set:

是一个String类型的无序集合。

场景:全局去重

sorted set:

是一个String类型的有序集合,通过给每一个元素一个固定的分数score来保持顺序。

场景:做排行榜应用,取TopN操作,范围查找

hash:

hash 是一个键值对集合,是一个 string 类型的 key和 value 的映射表,key 还是key,但是value是一个键值对(key-value)

场景:存放一些结构化的信息 CookieId 作为 Key,设置 30 分钟为缓存过期时间,能很好的模拟出类似 Session 的效果。

redis的线程模型

  • 文件事件处理器

redis基于reactor模式开发了网络事件处理器,这个处理器叫做文件事件处理器,file event handler。这个文件事件处理器,是单线程的,redis才叫做单线程的模型,采用IO多路复用机制同时监听多个socket,根据socket上的事件来选择对应的事件处理器来处理这个事件。

image.png

为啥redis单线程模型也能效率这么高?

1)纯内存操作

2)核心是基于非阻塞的IO多路复用机制

3)单线程反而避免了多线程的频繁上下文切换问题

讲讲Redis的数据淘汰机制?

Redis在每个服务客户端执行一个命令的时候,都会先检测使用的内存是否超额。

在Redis中,我们可以设置Redis的最大使用内存大小(server.maxmemory)。当Redis内存数据集大小上升到一定程度的时候,就会施行数据淘汰机制。Redis提供了一下6种数据淘汰机制:

volatile-lru :从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰。

volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰。

volatile-random:从已设置过期时间的数据集(server.db[i].expires)中随机挑选数据淘汰。

allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰。

allkeys-random:从数据集(server.db[i].dict)中随机挑选数据淘汰。

no-envivtion(驱逐):禁止驱逐数据。

LRU机制: Redis保存了lru计数器server.lrulock,会定时的去更新(redis定时程序severCorn()),每个Redis对象都会设置相应的lru值,每次访问对象的时候,Redis都会更新redisObject.lru。

LRU淘汰机制: 在数据集中随机挑选几个键值对,取出其中lru最大的键值对淘汰。所以,Redis并不能保证淘汰的数据都是最近最少使用的,而是随机挑选的键值对中的。

TTL机制: Redis数据集结构中保存了键值对过期时间表,即 redisDb.expires。

TTL淘汰机制: 在数据集中随机挑选几个键值对,取出其中最接近过期时间的键值对淘汰。所以,Redis并不能保证淘汰的数据都是最接近过期时间的,而是随机挑选的键值对中的。

说说Redis的数据一致性问题?

1) 双写模式

当数据更新时,更新数据库时同时更新缓存

image.png

存在问题

由于卡顿等原因,导致写缓存2在最前,写缓存1在后面就出现了不一致

2) 失效模式

数据库更新时将缓存删除

image.png

存在问题

当两个请求同时修改数据库,一个请求已经更新成功并删除缓存时又有读数据的请求进来,这时候发现缓存中无数据就去数据库中查询并放入缓存,在放入缓存前第二个更新数据库的请求成功,这时候留在缓存中的数据依然是第一次数据更新的数据

解决方法:

1、缓存的所有数据都有过期时间,数据过期下一次查询触发主动更新

2、读写数据的时候(并且写的不频繁),加上分布式的读写锁。

  1. 解决方案

无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?

  • 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可

  • 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。

  • 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。

  • 通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据可忽略);

总结

  • 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
  • 我们不应该过度设计,增加系统的复杂性
  • 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。

Redis的分布式怎么做?

四 Redis 的过期策略和内存淘汰机制

Redis 是否用到家,从这就能看出来。比如你 Redis 只能存 5G 数据,可是你写了 10G,那会删 5G 的数据。怎么删的,这个问题思考过么?

正解:Redis 采用的是定期删除+惰性删除策略。

为什么不用定时删除策略

定时删除,用一个定时器来负责监视 Key,过期则自动删除。虽然内存及时释放,但是十分消耗 CPU 资源。在大并发请求下,CPU 要将时间应用在处理请求,而不是删除 Key,因此没有采用这一策略。

定期删除+惰性删除如何工作

定期删除,Redis 默认每个 100ms 检查,有过期 Key 则删除。需要说明的是,Redis 不是每个 100ms 将所有的 Key 检查一次,而是随机抽取进行检查。如果只采用定期删除策略,会导致很多 Key 到时间没有删除。于是,惰性删除派上用场。

采用定期删除+惰性删除就没其他问题了么

不是的,如果定期删除没删除掉 Key。并且你也没及时去请求 Key,也就是说惰性删除也没生效。这样,Redis 的内存会越来越高。那么就应该采用内存淘汰机制。

在 redis.conf 中有一行配置:

maxmemory-policy volatile-lru

该配置就是配内存淘汰策略的:

  • noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 Key。(推荐使用,目前项目在用这种)(最近最久使用算法)
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 Key。(应该也没人用吧,你不删最少使用 Key,去随机删)
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 Key。这种情况一般是把 Redis 既当缓存,又做持久化存储的时候才用。(不推荐)
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 Key。(依然不推荐)
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 Key 优先移除。(不推荐)

五 Redis 和数据库双写一致性问题

一致性问题还可以再分为最终一致性和强一致性。数据库和缓存双写,就必然会存在不一致的问题。前提是如果对数据有强一致性要求,不能放缓存。我们所做的一切,只能保证最终一致性。

 

另外,我们所做的方案从根本上来说,只能降低不一致发生的概率。因此,有强一致性要求的数据,不能放缓存。首先,采取正确更新策略,先更新数据库,再删缓存。其次,因为可能存在删除缓存失败的问题,提供一个补偿措施即可,例如利用消息队列。

缓存穿透,即黑客故意去请求缓存中不存在的数据,导致所有的请求都怼到数据库上,从而数据库连接异常。

缓存穿透解决方案:

  • 利用互斥锁,缓存失效的时候,先去获得锁,得到锁了,再去请求数据库。没得到锁,则休眠一段时间重试。
  • 采用异步更新策略,无论 Key 是否取到值,都直接返回。Value 值中维护一个缓存失效时间,缓存如果过期,异步起一个线程去读数据库,更新缓存。需要做缓存预热(项目启动前,先加载缓存)操作。
  • 提供一个能迅速判断请求是否有效的拦截机制,比如,利用布隆过滤器,内部维护一系列合法有效的 Key。迅速判断出,请求所携带的 Key 是否合法有效。如果不合法,则直接返回。

缓存雪崩,即缓存同一时间大面积的失效,这个时候又来了一波请求,结果请求都怼到数据库上,从而导致数据库连接异常。

缓存雪崩解决方案:

  • 给缓存的失效时间,加上一个随机值,避免集体失效。
  • 使用互斥锁,但是该方案吞吐量明显下降了。
  • 双缓存。我们有两个缓存,缓存 A 和缓存 B。缓存 A 的失效时间为 20 分钟,缓存 B 不设失效时间。自己做缓存预热操作。
  • 然后细分以下几个小点:从缓存 A 读数据库,有则直接返回;A 没有数据,直接从 B 读数据,直接返回,并且异步启动一个更新线程,更新线程同时更新缓存 A 和缓存 B。

八 如何解决 Redis 的并发竞争 Key 问题

这个问题大致就是,同时有多个子系统去 Set 一个 Key。这个时候要注意什么呢?大家基本都是推荐用 Redis 事务机制。

 

但是并不推荐使用 Redis 的事务机制。因为我们的生产环境,基本都是 Redis 集群环境,做了数据分片操作。你一个事务中有涉及到多个 Key 操作的时候,这多个 Key 不一定都存储在同一个 redis-server 上。因此,Redis 的事务机制,十分鸡肋。

 

如果对这个 Key 操作,不要求顺序

这种情况下,准备一个分布式锁,大家去抢锁,抢到锁就做 set 操作即可,比较简单。

 

如果对这个 Key 操作,要求顺序

假设有一个 key1,系统 A 需要将 key1 设置为 valueA,系统 B 需要将 key1 设置为 valueB,系统 C 需要将 key1 设置为 valueC。

 

期望按照 key1 的 value 值按照 valueA > valueB > valueC 的顺序变化。这种时候我们在数据写入数据库的时候,需要保存一个时间戳。

 

假设时间戳如下

系统 A key 1 {valueA 3:00}

系统 B key 1 {valueB 3:05}

系统 C key 1 {valueC 3:10}

 

那么,假设系统 B 先抢到锁,将 key1 设置为{valueB 3:05}。接下来系统 A 抢到锁,发现自己的 valueA 的时间戳早于缓存中的时间戳,那就不做 set 操作了,以此类推。其他方法,比如利用队列,将 set 方法变成串行访问也可以。

RPC讲一下?

请说一下rpc和http的区别?       

一、区别:

1、传输协议

        RPC,可以基于TCP协议,也可以基于HTTP协议

        HTTP,基于HTTP协议

  2、传输效率

        RPC,使⽤用⾃自定义的TCP协议,可以让请求报⽂文体积更更⼩小,或者使⽤用HTTP2协议,也可以很好的减少报⽂文的体积,提⾼高传输效率

        HTTP,如果是基于HTTP1.1的协议,请求中会包含很多⽆无⽤用的内容,如果是基于HTTP2.0,那么简单的封装以下是可以作为⼀一个RPC来使⽤用的,这时标准RPC框架更更多的是服务治理理

3、性能消耗,主要在于序列列化和反序列列化的耗时

        RPC,可以基于thrift实现⾼高效的⼆二进制传输

        HTTP,⼤大部分是通过json来实现的,字节⼤大⼩小和序列列化耗时都⽐比thrift要更更消耗性能

4、负载均衡

        RPC,基本都⾃自带了了负载均衡策略略

        HTTP,需要配置Nginx,HAProxy来实现

5、服务治理(下游服务新增,重启,下线时如何不不影响上游调⽤用者)

        RPC,能做到⾃自动通知,不不影响上游 

        HTTP,需要事先通知,修改Nginx/HAProxy配置

二、总结:

RPC主要⽤用于公司内部的服务调⽤用,性能消耗低,传输效率⾼高,服务治理理⽅方便便。HTTP主要⽤用于对外的异构环境,浏览器器接⼝口调⽤用,APP接⼝口调⽤用,第三⽅方接⼝口调⽤用等。

三次握手和四次挥手?如果没有三次握手有问题吗?

序列号seq:占4个字节,用来标记数据段的顺序,TCP把连接中发送的所有数据字节都编上一个序号,第一个字节的编号由本地随机产生;给字节编上序号后,就给每一个报文段指派一个序号;序列号seq就是这个报文段中的第一个字节的数据编号。

确认号ack:占4个字节,期待收到对方下一个报文段的第一个数据字节的序号;序列号表示报文段携带数据的第一个字节的编号;而确认号指的是期望接收到下一个字节的编号;因此当前报文段最后一个字节的编号+1即为确认号。

确认ACK:占1位,仅当ACK=1时,确认号字段才有效。ACK=0时,确认号无效

同步SYN:连接建立时用于同步序号。当SYN=1,ACK=0时表示:这是一个连接请求报文段。若同意连接,则在响应报文段中使得SYN=1,ACK=1。因此,SYN=1表示这是一个连接请求,或连接接受报文。SYN这个标志位只有在TCP建产连接时才会被置1,握手完成后SYN标志位被置0。

终止FIN:用来释放一个连接。FIN=1表示:此报文段的发送方的数据已经发送完毕,并要求释放运输连接

PS:ACK、SYN和FIN这些大写的单词表示标志位,其值要么是1,要么是0;ack、seq小写的单词表示序号。

字段 含义 URG 紧急指针是否有效。为1,表示某一位需要被优先处理 ACK 确认号是否有效,一般置为1。 PSH 提示接收端应用程序立即从TCP缓冲区把数据读走。 RST 对方要求重新建立连接,复位。 SYN 请求建立连接,并在其序列号的字段进行序列号的初始值设定。建立连接,设置为1 FIN 希望断开连接。 三次握手过程理解

image.png

第一次握手:建立连接时,客户端发送syn包(syn=x)到服务器,并进入SYN_SENT状态,等待服务器确认;SYN:同步序列编号(Synchronize Sequence Numbers)。

第二次握手:服务器收到syn包,必须确认客户的SYN(ack=x+1),同时自己也发送一个SYN包(syn=y),即SYN+ACK包,此时服务器进入SYN_RECV状态;

第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=y+1),此包发送完毕,客户端和服务器进入ESTABLISHED(TCP连接成功)状态,完成三次握手。

四次挥手过程理解

image.png

1)客户端进程发出连接释放报文,并且停止发送数据。释放数据报文首部,FIN=1,其序列号为seq=u(等于前面已经传送过来的数据的最后一个字节的序号加1),此时,客户端进入FIN-WAIT-1(终止等待1)状态。 TCP规定,FIN报文段即使不携带数据,也要消耗一个序号。

2)服务器收到连接释放报文,发出确认报文,ACK=1,ack=u+1,并且带上自己的序列号seq=v,此时,服务端就进入了CLOSE-WAIT(关闭等待)状态。TCP服务器通知高层的应用进程,客户端向服务器的方向就释放了,这时候处于半关闭状态,即客户端已经没有数据要发送了,但是服务器若发送数据,客户端依然要接受。这个状态还要持续一段时间,也就是整个CLOSE-WAIT状态持续的时间。

3)客户端收到服务器的确认请求后,此时,客户端就进入FIN-WAIT-2(终止等待2)状态,等待服务器发送连接释放报文(在这之前还需要接受服务器发送的最后的数据)。

4)服务器将最后的数据发送完毕后,就向客户端发送连接释放报文,FIN=1,ack=u+1,由于在半关闭状态,服务器很可能又发送了一些数据,假定此时的序列号为seq=w,此时,服务器就进入了LAST-ACK(最后确认)状态,等待客户端的确认。

5)客户端收到服务器的连接释放报文后,必须发出确认,ACK=1,ack=w+1,而自己的序列号是seq=u+1,此时,客户端就进入了TIME-WAIT(时间等待)状态。注意此时TCP连接还没有释放,必须经过2∗∗MSL(最长报文段寿命)的时间后,当客户端撤销相应的TCB后,才进入CLOSED状态。

6)服务器只要收到了客户端发出的确认,立即进入CLOSED状态。同样,撤销TCB后,就结束了这次的TCP连接。可以看到,服务器结束TCP连接的时间要比客户端早一些。

常见面试题 【问题1】为什么连接的时候是三次握手,关闭的时候却是四次握手?

答:因为当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当Server端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉Client端,"你发的FIN报文我收到了"。只有等到我Server端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四步握手。

【问题2】为什么TIME_WAIT状态需要经过2MSL(最大报文段生存时间)才能返回到CLOSE状态?

答:虽然按道理,四个报文都发送完毕,我们可以直接进入CLOSE状态了,但是我们必须假象网络是不可靠的,有可以最后一个ACK丢失。 所以TIME_WAIT状态就是用来重发可能丢失的ACK报文。 在Client发送出最后的ACK回复,但该ACK可能丢失。Server如果没有收到ACK,将不断重复发送FIN片段。 所以Client不能立即关闭,它必须确认Server接收到了该ACK。 Client会在发送出ACK之后进入到TIME_WAIT状态。、Client会设置一个计时器,等待2MSL的时间。如果在该时间内再次收到FIN,那么Client会重发ACK并再次等待2MSL。 所谓的2MSL是两倍的MSL(Maximum Segment Lifetime)。MSL指一个片段在网络中最大的存活时间,2MSL就是一个发送和一个回复所需的最大时间。如果直到2MSL,Client都没有再次收到FIN,那么Client推断ACK已经被成功接收,则结束TCP连接。

【问题3】为什么不能用两次握手进行连接?

答:3次握手完成两个重要的功能,既要双方做好发送数据的准备工作(双方都知道彼此已准备好),也要允许双方就初始序列号进行协商,这个序列号在握手过程中被发送和确认。

现在把三次握手改成仅需要两次握手,死锁是可能发生的。

作为例子,考虑计算机S和C之间的通信,假定C给S发送一个连接请求分组,S收到了这个分组,并发 送了确认应答分组。按照两次握手的协定,S认为连接已经成功地建立了,可以开始发送数据分组。

可是,C在S的应答分组在传输中被丢失的情况下,将不知道S 是否已准备好,不知道S建立什么样的序列号,C甚至怀疑S是否收到自己的连接请求分组。在这种情况下,C认为连接还未建立成功,将忽略S发来的任何数据分 组,只等待连接确认应答分组。而S在发出的分组超时后,重复发送同样的分组。这样就形成了死锁。

【问题4】如果已经建立了连接,但是客户端突然出现故障了怎么办?

TCP还设有一个保活计时器,显然,客户端如果出现故障,服务器不能一直等下去,白白浪费资源。服务器每收到一次客户端的请求后都会重新复位这个计时器,时间通常是设置为2小时,若两小时还没有收到客户端的任何数据,服务器就会发送一个探测报文段,以后每隔75秒钟发送一次。若一连发送10个探测报文仍然没反应,服务器就认为客户端出了故障,接着就关闭连接。

Http请求过程,DNS解析的过程?

HTTP请求过程-域名解析和TCP三次握手建立链接

我们在浏览器输入 www.baidu.com 想要进入百度首页,但是这是个域名,没法准确定位到服务器的位置,所以需要通过域名解析,把域名解析成对应的ip地址,然后通过ip地址查找目的主机。整个访问过程可以概括为:

  1. 域名解析
  2. 发起TCP三次握手建立连接
  3. 建立连接后发起http请求
  4. 服务器响应请求,浏览器获取html源码
  5. 浏览器解析html代码,并请求相关css,js和图片资源
  6. 浏览器渲染页面

一、域名解析过程

  在浏览器输入一串域名要访问某网站的时候,浏览器帮我们做了如下事情(以Chrome浏览器和windows系统为例):

  1. Chrome浏览器首先检查自己本地是缓存是否有对应的域名,有则直接使用。【查看Chrome浏览器dns缓存地址:chrome://net-internals/#dns
  2. 如果浏览器缓存中没有,则查询系统DNS缓存中的域名表,有则直接使用。【windows查看域名表的命令:ipconfig /displaydns】
  3. 系统缓存中还是没有,则检查hosts文件中的映射表。【windows中hosts文件路径:C:\Windows\System32\drivers\etc】
  4. 本地实在找不到,则向DNS域名服务器发起请求查询。【DNS服务器IP是本地配置的首选服务器,一般常用的有114.114.114.114(电信运营商提供)和8.8.8.8(Google提供)】
      • DNS服务器首先查找自身的缓存,有对应的域名ip则返回结果
      • 如果缓存中查找不到,DNS服务器则发起迭代DNS请求,首先向根域服务器发起请求查询,假如本次请求的是www.baidu.com,根域服务器发现这是一个com的顶级域名,就把com域的ip地址返回给DNS服务器
      • DNS服务器向com域ip地址发起请求,查询该域名的ip,此时该服务器返回了baidu.com的DNS地址。
      • 最后DNS服务器又向baidu.com的DNS地址发起查询请求,最后找到了完整的ip路径返回给DNS服务器,DNS再把ip信息返回给windows内核,内核再返回给浏览器,于是浏览器就知道该域名对应的ip地址了,可以开始进一步请求了。

      二、建立TCP连接

  

  第一次握手:客户端向服务器发送SYN报文,并发送客户端初始序列号Seq=X;等待服务器确认,

  第二次握手:服务器接收客户端的SYN报文,然后向客户端返回一个包SYN+ACK响应报文,并发送初始序列号Seq=Y

  第三次握手:客户端接受SYN+ACK报文,并向服务器发送一个ACK确认报文,至此连接建立

  【建立连接的最重要目是让连接的双方交换初始序号(ISN, Initial Sequence Number),所以再响应的ACK报文中会包含序列号递增序列】

1、HTTP请求和响应格式

1.1.http请求格式

  http请求格式由四部分组成:请求行、请求头、空行、消息体

  

  请求行:是请求消息的第一行,由请求方式(GET/POST/DELETE/PUT)、请求资源路径、http版本号组成

  请求头:请求头中的信息有和缓存相关的头(Cache-Control,If-Modified-Since)、客户端身份信息(User-Agent)等等

  空行:空行表示消息头的内容到此为止,接下来该解析消息体了,(所以空行必不可少)

  消息体:并不是所有的请求都有消息体。

  ------------------------------------------

  http响应格式和请求格式类似,包括:状态行、响应头、空行、消息体

  

  状态行:由http版本号、状态码、状态说明组成

  响应头:响应头是服务器传递给客户端用于说明服务器的一些信息,以及将来继续访问该资源时的策略。

  空行:空行表示消息头的内容到此为止,接下来该解析消息体了,(所以空行必不可少)

  消息体:就是服务器返回的数据信息

2、HTTP请求过程

  http请求是在三次握手建立tcp连接后开始进行的,基本流程如下:

(1)    建立TCP连接

  在HTTP工作开始之前,Web浏览器首先要通过网络与Web服务器建立连接,该连接是通过TCP来完成的,该协议与IP协议共同构建Internet,即著名的TCP/IP协议族,因此Internet又被称作是TCP/IP网络。HTTP是比TCP更高层次的应用层协议,根据规则,只有低层协议建立之后才能,才能进行更层协议的连接,因此,首先要建立TCP连接,一般TCP连接的端口号是80

(2)    Web浏览器向Web服务器发送请求命令

  一旦建立了TCP连接,Web浏览器就会向Web服务器发送请求命令例如:

(3)    Web浏览器发送请求头信息

  浏览器发送其请求命令之后,还要以头信息的形式向Web服务器发送一些别的信息,之后浏览器发送了一空白行来通知服务器,它已经结束了该头信息的发送。

(4)    Web服务器应答

  客户机向服务器发出请求后,服务器会客户机回送应答,HTTP/1.1 200 OK应答的第一部分是协议的版本号和应答状态码

(5)    Web服务器发送应答头信息

  正如客户端会随同请求发送关于自身的信息一样,服务器也会随同应答向用户发送关于它自己的数据及被请求的文档。

(6)    Web服务器向浏览器发送数据

  Web服务器向浏览器发送头信息后,它会发送一个空白行来表示头信息的发送到此为结束,接着,它就以Content-Type应答头信息所描述的格式发送用户所请求的实际数据

(7)    Web服务器关闭TCP连接

  一般情况下,一旦Web服务器向浏览器发送了请求数据,它就要关闭TCP连接,然后如果浏览器或者服务器在其头信息加入了这行代码Connection:keep-alive,TCP连接在发送后将仍然保持打开状态,于是,浏览器可以继续通过相同的连接发送请求。保持连接节省了为每个请求建立新连接所需的时间,还节约了网络带宽。

 

3、浏览器获取资源和渲染过程

   1. 用户输入网址(假设是个 HTML 页面,第一次访问,无缓存情况),浏览器向服务器发出HTTP请求,服务器返回 HTML 文件; (善用缓存,减少HTTP请求,减轻服务器压力)

           2. 浏览器载入 HTML 代码,发现 内有一个 引用外部 CSS 文件,则浏览器立即发送CSS文件请求,获取浏览器返回的CSS文件;  (CSS文件合并,减少HTTP请求)

           3. 浏览器继续载入 HTML 中 部分的代码,并且 CSS 文件已经拿到手了,可以开始渲染页面了;                              (CSS文件需要放置最上面,避免网页重新渲染)

           4. 浏览器在代码中发现一个 标签引用了一张图片,向服务器发出请求此时浏览器不会等到图片下载完,而是继续渲染后面的代码;    (图片文件合并,减少HTTP请求)

           5. 服务器返回图片文件,由于图片占用了一定面积,影响了后面段落的排布,因此浏览器需要回过头来重新渲染这部分代码;   (最好图片都设置尺寸,避免重新渲染)

           6. 浏览器发现了一个包含一行 JavaScript 代码的 该js代码;              (script最好放置页面最下面)                   

           7. js脚本执行了语句,它令浏览器隐藏掉代码中的某个

,突然就少了一个元素,浏览器不得不重新渲染这部分代码;   (页面初始化样式不要使用js控制)   

           8. 终于等到了 的到来,浏览器渲染完毕

           9. 如果换肤的话,JavaScript 让浏览器换了一下 标签的 CSS 路径;

          10. 浏览器向服务器请求了新的CSS文件,重新渲染页面。