面试高频 - Java面试

面向对象三大特征和七大原则

面向对象三大特征和七大原则

三大特征

封装

封装 就是对属性和方法的载体类,只能通过其提供的接口(方法)来访问,而把实现细节隐藏起来.具体实现对程序员来说是透明的,封装的好处在于对类内部的改变,不会影响到其他代码

  • 封装的做法: 私有属性(private修饰符修饰属性)、提供public的读(getXX)写(setXX)方法、在构造中调用方法.所有的非常量属性基本都需要封装.
  • 封装的好处:隐藏类的实现细节、对所有用户提供统一的接口、增强执行效果、易于维护和扩展

继承

继承 是一种关系,逻辑上满足子类is a 父类的关系才使用继承. 子类继承父类的属性和非私有方法.不能继承父类的构造,继承使用关键字extends,类单继承,接口多继承.

  • 在构造子类对象时,依次调用父类的构造(子类默认调用父类的无参构造.可以使用super(参数列表)来调用指定的父类的含参构造)到Object为止.再调用子类自身的; 子类调用父类的构造时,父类的构造只能调用一个且必须写在子类构造的第一句.

多态

多态 是指允许不同类的对象对同一消息作出响应。多态性包括参数化多态性和包含多态性。多态性语言具有灵活、抽象、行为共享、代码共享的优势,很好的解决了应用程序函数同名问题; 多态的类型有下面4种:

基本类型的多态: 拆箱、装箱.

本质上是基本类型之间的自动类型转换,Java语言中将8种数据类型都分别封装了一个类,这些类中封装了各基本数据类型的属性和基本运算.

基本类型自动转换为对应的封装类的操作叫做自动装箱操作,反之叫自动拆箱操作,自动装箱操作有一个自动装箱池(范围为-128~127).只要自动装箱的数在自动装箱池范围内,则直接去池中找数据.

方法的多态: 重载、重写.
  • 重写(overriding): 父类继承过来的方法对子类不合适时子类可以改变该方法的实现,这种操作叫做方法的重写/覆盖(继承是重写的前提条件); 重写要求:

    1. 返回值、方法名和参数相同((5.0以后允许返回子类类型));
    2. 子类异常不能超出父类异常;
    3. 子类访问级别不能低于父类访问级别.
  • 重载(overloading): 重载是在同一个类中存在两个或两个以上的同名方法,但是参数不同(参数个数不同、类型不同、顺序不同<(int,String)和(String,int)是不一样的>),方法体也不相同. 返回值类型可以相同可以不相同.最常用的重载例子便是构造函数。

类或者接口的多态: 父类的引用指向子类的对象

父类的引用指向子类的对象(Person p = new Student())就发生了多态, 该场景下:

扫描二维码关注公众号,回复: 8286254 查看本文章
  • 只能使用父类中方定义的属性和方法
  • 子类中定义的不能直接使用
  • 子类复写了父类的方法,此时调用情况根据方法是否static而不同 [static(调用父类),非static(调用子类)].
  • 如果想使用子类中定义的方法,可以强制类型转换(判断是否可以转换,用instance of运算符来判断对象的类型)

程序示例

class A {
    int a = 1;
    static int b = 20;
    int c = 3;
    double d = 2.0;
    
    void show() {
        System.out.println("Class A: a=" + a + "\td=" + d + "\tc=" + c + "\tb=" + b);
    }
    
    void common(){
        System.out.println("Class A: method common()");
    }
    
    static void execute(){
        System.out.println("Class A: method excute()");
    }
}

class B extends A {
    
    float a = 3.0f;
    static int b = 30;
    int c = 5;
    String d = "Java program.";

    void show() {
        super.show();
        System.out.println("Class B: a=" + a + "\td=" + d + "\tc=" + c + "\tb=" +b);
    }
    
    void common(){
        System.out.println("Class B: method common()");
    }
    
    static void execute(){
        System.out.println("Class B: method execute()");
    }
    
    public static void main(String[] args) {
        A a = new B();
        a.show();
        System.out.println("----------------------");
        a.common();
        System.out.println("----------------------");
        a.execute();
    }
}
复制代码

执行输出

Class A: a=1    d=2.0    c=3    b=20
Class B: a=3.0    d=Java program.    c=5    b=30
----------------------
Class B: method common()
----------------------
Class A: method excute()
复制代码
传参时的多态

【面试题】Java中的参数传递是传传递还是传引用?

Java语言的方法调用只支持参数的值传递, 当一个对象实例作为一个参数被传递到方法中时,参数的值就是对该对象的引用。对象的属性可以在被调用过程中被改变,但对对象引用的改变是不会影响到调用者的。

程序示例

public class Test {

    public static void invoke(int num, Person person){
        num = 222;
        person.setAge(20);
        person.setName("李四");
        System.out.println(num + "," + person.getName() + "," + person.getAge());
    }

    public static void main(String[] args) {
        int num = 111;
        Person person = new Person("张三", 10);
        invoke(num, person);
        System.out.println(num + "," + person.getName() + "," + person.getAge());
    }

    @Data
    static class Person{

        public Person(String name, int age) {
            this.name = name;
            this.age = age;
        }
        private String name;

        private int age;

    }
}
复制代码

程序输出

222,李四,20
111,李四,20
复制代码

七大原则

  1. 单一职责原则(一个类只做它该做的事情)、
  2. 开闭原则(更改性封闭,扩展性开放)、
  3. 依赖倒转原则(面向接口编程)、
  4. 里氏替换原则(任何时候都可以用子类型替换掉父类型)、
  5. 接口隔离原则(接口要小而专,绝不能大而全)、
  6. 合成复用原则(优先使用聚合或合成关系复用代码)、
  7. 迪米特原则(对象与对象之间应该使用尽可能少的方法来关联)

Java程序初始化顺序

Java程序初始化顺序

Java语言中当实例化对象时,对象所在的类的所有成员变量首先要进行实例化, 只有当所有类成员实例化后,才会调用对象所在类的构造函数创建对象; Java程序的初始化一般遵循3个原则(优先级依次递减):

  • 1). 静态对象(变量)优先于非静态对象(变量)初始化.
  • 2). 分类优先于子类进行初始化.
  • 3). 按照成员变量的定义顺序进行初始化.即使变量定义散布于方法定义之中, 他们依然在任何方法(包括构造函数)被调用之前先初始化.

程序示例

class B {

    static {
        System.out.println("B with static ");
    }

    {
        System.out.println("B with code block");
    }

    public B(){
        System.out.println("B with construct");
    }

}

class A extends B {

    static {
        System.out.println("A with static");
    }

    {
        System.out.println("A with code block");
    }

    public A(){
        System.out.println("A with construct");
    }
}
复制代码

场景1用例:

public class Test {

    public static void main(String[] args) {
        new B();
        System.out.println("------");
        new B();
    }
}
复制代码

场景1输出:

B with static 
B with code block
B with construct
------
B with code block
B with construct
复制代码

场景2用例:

public class Test {

    public static void main(String[] args) {
        new A();
    }
}
复制代码

场景2输出:

B with static 
A with static
B with code block
B with construct
A with code block
A with construct
复制代码

场景3用例:

public class Test {

    public static void main(String[] args) {
        new A();
        System.out.println("------");
        new A();
    }
}
复制代码

场景3输出:

B with static 
A with static
B with code block
B with construct
A with code block
A with construct
------
B with code block
B with construct
A with code block
A with construct    
复制代码

Java语言的变量类型

Java语言的变量类型

在Java语言中,变量的类型只要有3种: 成员变量静态变量局部变量.

成员变量

类成员变量的作用范围与类的实例化对象的作用范围相同, 当类被实例化时, 成员变量就会在内存中分配空间并初始化,直到这个被实例化对象的生命周期结束时成员变量的生命周期才结束.

类成员变量作用域有4种,访问权限范围由大到小依次为: public > protected > default > private.

  • public: 表明该成员变量或方法对所有类或对象都是可见的,所有类或对象都可以直接访问.
  • protected: 表明该成员变量或方法对自己及其子类是可见的,除此之外的其他类或对象都没有访问权限.
  • default: 表明该成员变量或方法只有自己和与其位于同一包内的类可见, 若父类与子类位于不同的包内,则无访问权限.
  • private: 表明该成员变量或方法是私有的,只有当前类对其具有访问权限,除此之外 的其他类或者对象都没有访问权限.

注意: 这些修饰符只能修饰成员变量,不能用来修饰局部变量, private与protected不能用来修饰类(只有 public、 abstract或 final 能用来修饰类).

静态变量

被static修饰的成员变量称为静态变量或全局变量,与成员变量不同的是,静态变量不依赖于特定的实例,而是被所有实例所共享; 也就是说: 只要一个类被加载, JVM就会给类的静态变量分配存储空间. 因此就可以通过类名和变量名来访问静态变量.

局部变量

局部变量的作用域与可见性为它所在的花括号内.

理解Java-位运算

位运算符主要针对二进制,它包括了:“与”、“非”、“或”、“异或”。

数据基本单位

在了解运算符前,先了解一下数据的基本单位

  • BIT(比特): 一个二进制位(最小数据单位), 比如:0
  • BYTE(字节): 1BYTE = 8BIT, 比如:01010110
  • KB(千字节): 1KB = 1024BYTE
  • MB(兆字节): 1MB = 1024KB
  • GB(吉字节): 1GB = 1024MB
  • TB(太字节): 1TB=1024GB

Java中的基本数据类型

不同语言中的数据长度可能不一样, 这里介绍一下Java语言中的基础数据长度

数据类型与对应长度

类型 长度
boolean -
char 16bit
byte 8bit
short 16bit
int 32bit
long 64bit
float 32bit
double 64bit

程序示例

@Test
public void test(){
   // 输出: 1
   System.out.println(Byte.BYTES);
   // 输出: 2
   System.out.println(Short.BYTES);
   // 输出: 4
   System.out.println(Integer.BYTES);
   // 输出: 8
   System.out.println(Long.BYTES);
   // 输出: 4
   System.out.println(Float.BYTES);
   // 输出: 8
   System.out.println(Double.BYTES);
   // 输出: 11111111111111111111111111111110
   System.out.println(Integer.toBinaryString(-2));
   // 输出: 1111111111111111111111111111111111111111111111111111111111111110
   System.out.println(Long.toBinaryString(-2L));
   char c = '苏';
   // 输出: 苏
   System.out.println(c);
}
复制代码

^(异或)

参与运算的两个数, 如果两个相应位相同(二进制),则结果为0,否则为1. 即:0^0=0, 1^0=1, 0^1=1, 1^1=0

运算说明

操作 二进制 十进制
- 000000000000000000000000000000001 1
- 000000000000000000000000000000010 2
^ 000000000000000000000000000000011 3

程序测试

@Test
public void test(){
   // 输出: 3
   System.out.println(1^2);
}
复制代码

&(与)

参与运算的两个数, 如果两个相应位都为1(二进制),结果才为1,否则结果为0. 即:0^0=0, 1^0=1, 0^1=1, 1^1=1

运算说明

操作 二进制 十进制
- 000000000000000000000000000000001 1
- 000000000000000000000000000000010 2
& 000000000000000000000000000000000 0

程序测试

@Test
public void test(){
   // 输出: 0
   System.out.println(1&2);
}
复制代码

| (或)

参与运算的两个数, 如果两个相应位只要有一个为1(二进制),那么结果就是1,否则就为0. 即:0^0=0, 1^0=1, 0^1=1, 1^1=1

运算说明

操作 二进制 十进制
- 000000000000000000000000000000001 1
- 000000000000000000000000000000010 2
000000000000000000000000000000011 3

程序测试

@Test
public void test(){
   // 输出: 3
   System.out.println(1|2);
}
复制代码

~(非)

参数计算的数,如果位为0(二进制),结果是1,如果位为1,结果是0.

运算说明

二进制 十进制 运算 二进制 十进制
000000000000000000000000000000001 1 ~ 11111111111111111111111111111110 -2
000000000000000000000000000000010 2 ~ 11111111111111111111111111111101 -3

程序测试

@Test
public void test(){

   // 输出: -2
   System.out.println(~1);
   // 输出: 11111111111111111111111111111110
   System.out.println(Integer.toBinaryString(~1));

   // 输出: -3
   System.out.println(~2);
   // 输出: 11111111111111111111111111111101
   System.out.println(Integer.toBinaryString(~2));
}
复制代码

关于位运算的面试题

【面试题】实现两个数的交换

思路: 两个数做异或,会计算出来一个中间数(即便这两个数相同,那么计算结果为0也满足),用这个中间数做交换时的中间引用停留即可.

代码实现

@Test
public void find(){
   int a = 1, b=2;
   System.out.println("交换前: a = " + a + ", b = " + b);
   a = a ^ b;
   b = a ^ b;  // ((a^b) ^ b) = a
   a = a ^ b;  // ((a^b) ^ b) = a
   System.out.println("交换后: a = " + a + ", b = " + b);
}
复制代码

【面试题】实现字符串翻转

思路: 使用异或进行高低位转换

代码实现

/**
 * @param str 待返转的字符串
 * @return 翻转后的字符串
 */
public static String reverse(String str){
   char[] chars = str.toCharArray();
   int low = 0;
   int top = chars.length - 1;
   while (low < top){
       chars[low] ^= chars[top];
       chars[top] ^= chars[low];
       chars[low] ^= chars[top];
       low++;
       top--;
   }
   return new String(chars);
}
复制代码

【面试题】一堆数, 除过一个数只出现了一次, 其它数均出现了2n次(n>=1), 找出该数.

分析: 因为相同的两个数做^结果为0, 0^任何数=任何数. 将这堆数从第一个开始,一直到最后一个进行异或运算, 那么最后结果值就是那个只出现了一次的数.

代码实现

@Test
public void findOne(){
   int[] arr = new int[] {1, 1, 2, 2, 3, 5, 5, 7, 7, 6, 6};
   // 从第一个数标开始
   int result = arr[0];
   // 以此进行后面的数据的异或运算
   for(int i = 1; i < arr.length; i++){
       result = result ^ arr[i];
   }
   // 程序输出: 3
   System.out.println(result);
}
复制代码

【面试题】java中 ||| , &&& 有什么区别

  • &(与): 位运算符,也兼有逻辑预算符的功能.

  • &&(短路与): 只是逻辑运算符. &与&&作为逻辑运算符时有如下区别:

  • &: 无论&左边是否为false,他都会继续检验右边的boolean值。

  • &&: 只要检测到左边值为false时, 就会直接判断结果,不会在检验右边的值(因为"与"有一个false最后结果就是false了),所以&&的执行效率更高,所以逻辑运算时一般都是使用&&.

  • |(或)与||(短路或) 区别 同&(与)和&&(短路与)相似.

Java中的Object类.

Java中的Object类

Object有哪些公用方法

  • 1.clone方法

保护方法, 创建并返回此对象的一个副本, 用于实现对象的浅复制, 只有实现了Cloneable接口才可以调用该方法,否则抛出CloneNotSupportedException异常; JAVA里除了8种基本类型传参数是值传递,其他的类对象传参数都是引用传递,我们有时候不希望在方法里将参数改变,这时就需要在类中复写clone方法.

  • 2.getClass方法

final方法,返回一个对象的运行时类。

  • 3.toString方法

返回该对象的字符串表示, 该方法用得比较多,一般子类都有覆盖。

  • 4.finalize方法

该方法用于释放资源。当垃圾回收器确定不存在对该对象的更多引用时,由对象的垃圾回收器调用此方法。子类重写 finalize 方法,以配置系统资源或执行其他清除。

在启用某个对象的 finalize 方法后,将不会执行进一步操作,直到 Java 虚拟机再次确定尚未终止的任何线程无法再通过任何方法访问此对象,其中包括由准备终止的其他对象或类执行的可能操作,在执行该操作时,对象可能被丢弃。

对于任何给定对象,Java 虚拟机最多只调用一次 finalize 方法。

  • 5.equals方法

该方法是非常重要的一个方法。一般equals和==是不一样的,但是在Object中两者是一样的。子类一般都要重写这个方法。

  • 6.hashCode方法

该方法用于哈希查找,可以减少在查找中使用equals的次数,重写了equals方法一般都要重写hashCode方法。这个方法在一些具有哈希功能的Collection中用到。

一般必须满足obj1.equals(obj2)==true1。可以推出obj1.hashCode()==obj2.hashCode()`,但是hashCode相等不一定就满足equals。不过为了提高效率,应该尽量使上面两个条件接近等价。

如果不重写hashCode(),在HashSet中添加两个equals的对象,会将两个对象都加入进去。

  • 7.wait方法

wait方法就是使当前线程等待该对象的锁,当前线程必须是该对象的拥有者,也就是具有该对象的锁。wait()方法一直等待,直到获得锁或者被中断。wait(long timeout)设定一个超时间隔,如果在规定时间内没有获得锁就返回。

调用该方法后当前线程进入睡眠状态,直到以下事件发生。

    1. 其他线程调用了该对象的notify方法。
  • 2)其他线程调用了该对象的notifyAll方法。
  • 3)其他线程调用了interrupt中断该线程。
  • 4)时间间隔到了。

此时该线程就可以被调度了,如果是被中断的话就抛出一个InterruptedException异常。

  • 8.notify方法

该方法唤醒在该对象上等待的某个线程。

  • 9.notifyAll方法

该方法唤醒在该对象上等待的所有线程。

面试延伸

【面试题】Java是值传递还是引用传递

Java是指传递, 对于基本类型传递的是值, 对于引用类型传递的是指针的地址

  • 值传递:方法调用时,实际参数把它的值传递给对应的形式参数,方法执行中形式参数值的改变不影响实际参数的值。

  • 引用传递:也称为传地址, 方法调用时实际参数的引用(地址,而不是参数的值)被传递给方法中相对应的形式参数,在方法执行中对形式参数的操作实际上就是对实际参数的操作,方法执行中形式参数值的改变将会影响实际参数的值.

【面试题】阐述finalfinallyfinalize的区别

  • final:修饰符(关键字)有三种用法:(1)修饰类:表示该类不能被继承;(2)修饰方法:表示方法不能被重写;(3)修饰变量:表示变量只能一次赋值以后值不能被修改(常量)

  • finally:通常放在try…catch…的后面构造总是执行代码块(try{}里的return语句,其后finally{}里的代码会方法返回给调用者前执行),这就意味着程序无论正常执行还是发生异常,这里的代码只要JVM不关闭都能执行,可以将释放外部资源的代码写在finally块中。

  • finalize:Object类中定义的方法,Java中允许使用finalize()方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作(如关闭连接、关闭文件)。这个方法一般不会显示的调用, 通常是由垃圾收集器在销毁对象时调用的,通过重写finalize()方法可以整理系统资源或者执行其他清理工作。

【面试题】equals== 的区别

  • == 比较的是变量(栈)内存中存放的对象的(堆)内存地址,用来判断两个对象的地址是否相同,即是否是指相同一个对象。比较的是真正意义上的指针操作.

  • equals 比较的是两个对象的内容是否相等,由于所有的类都是继承自java.lang.Object类的,所以适用于所有对象,而equals()可以返回true或者false主要取决于重写equals方法的实现逻辑.

代码示例

public static void main(String[] args) {
   String a = new String("ab"); // a 为一个引用
   String b = new String("ab"); // b为另一个引用,对象的内容一样
   String aa = "ab"; // 放在常量池中
   String bb = "ab"; // 从常量池中查找
   if (aa == bb) // true
       System.out.println("aa==bb");
   if (a == b)   // false,非同一对象
       System.out.println("a==b");
   if (a.equals(b))  // true
       System.out.println("aEQb");
   if (42 == 42.0) { // true
       System.out.println("true");
   }
}
复制代码

【面试题】对象值相同(x.equals(y)为true,但却可以有不同的hash值?

如果两个对象满足equals为true,既 x.equals(y)==true, 那么他的哈希码(hash code)必然相同.

如果两个对象的hashCode相同, 它们并不一定相同.

【面试题】如何解决Hash冲突

通过构造性能良好的哈希函数,可以减少冲突,但一般不可能完全避免冲突,因此解决冲突是哈希法的另一个关键问题。创建哈希表和查找哈希表都会遇到冲突,两种情况下解决冲突的方法应该一致。下面以创建哈希表为例,说明解决冲突的方法。常用的解决冲突方法有以下四种:

  • 开放定址法

又称为开放散列法

基本思想是: 当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2, …;直到找出一个不冲突的哈希地址pi ,将相应元素存入其中.

这种方法有一个通用的再散列函数形式: Hi = ( H(key) + di ) % m 其中 i=1,2,…,n

说明: H(key)为哈希函数, m为表长, di称为增量序列, 增量序列的取值方式不同,相应的再散列方式也不同, 主要有以下三种:

  • 线性探测再散列

di i=1,2,3,…,m-1 这种方法的特点是: 冲突发生时,顺序查看表中下一单元,直到找出一个空单元或查遍全表。

  • 二次探测再散列

di = 12,-12,22,-22,…,k2,-k2 (k<=m/2) 这种方法的特点是: 冲突发生时,在表的左右进行跳跃式探测,比较灵活。

  • 伪随机探测再散列

di = 伪随机数序列

示例说明

例如: 已知哈希表长度m=11,哈希函数为:H(key) = key % 11,则H(47)=3,H(26)=4,H(60)=5,假设下一个关键字为69,则H(69)=3,与47冲突.

  • 用线性探测再散列处理冲突: 下一个哈希地址为H1=(3 + 1) % 11 = 4,仍然冲突,再找下一个哈希地址为H2= (3 + 2) % 11 = 5,还是冲突,继续找下一个哈希地址为H3=(3 + 3)% 11 = 6,此时不再冲突,将69填入5号单元。

  • 用二次探测再散列处理冲突: 下一个哈希地址为H1=(3 + 12)% 11 = 4,仍然冲突,再找下一个哈希地址为H2= (3 - 12) % 11 = 2,此时不再冲突,将69填入2号单元。

  • 用伪随机探测再散列处理冲突: 设伪随机数序列为: 2,5,9,……..,则下一个哈希地址为H1=(3 + 2)% 11 = 5,仍然冲突,再找下一个哈希地址为H2=(3 + 5)% 11 = 8,此时不再冲突,将69填入8号单元。

  • 再哈希法

基本思想: 同时构造多个不同的哈希函数: Hi=RH1(key) i=1, 2, ..., k

当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)..., 直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间.

  • 链地址法

基本思想: 将所有哈希地址相同的记录都链接在同一单链表中; 并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行.

链地址法适用于经常进行插入和删除的情况.

  • 建立公共溢出区

基本思想: 将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表

Java线程

Java线程

线程状态

线程基本上有5种状态,分别是:新建就绪运行阻塞死亡

  • 新建状态(New)

当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();

  • 就绪状态(Runnable)

当调用线程对象的start()方法,线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行; 如: t.start();

  • 运行状态(Running)

当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中.

  • 阻塞状态(Blocked)

处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:

  1. 等待阻塞

运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;

  1. 同步阻塞

线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;

  1. 其他阻塞

通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

  • 死亡状态(Dead)

线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

线程生命周期

一个线程的声明周期一般从新建状态(New)开始,到死亡状态(Dead)结束,中间可以存在许多中可能。

如上图所示,一般情况下会有4个分支情况:

  • 正常情况(红色箭头),

  • 发生锁阻塞(同步阻塞)的情况(蓝色箭头),

  • 发生等待阻塞的情况(黄色箭头),

  • 发生其他阻塞的情况(黑色箭头),分别对应上图4个不同颜色的流向

  • 正常情况

如上图中红色箭头所示,正常状态下线程的声明周期是这样的:NEW -> RUNNABLE -> RUNNING -> DEAD。

  • 发生锁阻塞(同步阻塞)

如上图中蓝色箭头所示,当线程进入 RUNNING 状态时进入了 synchronized 方法块,这时就会发生锁阻塞,线程进入一个 Lock Pool 锁池中。当其获得锁之后,又进入到可运行状态。当CPU分片轮询到它的时候,它就再次运行,直至 DEAD 状态。

  • 发生等待阻塞

如上图中蓝色箭头所示,当线程进入 RUNNING 状态时遇到了 wait() 方法,这时就会发生等待阻塞,它会等待其他线程调用 notify() 方法释放锁之后才可恢复到可运行状态。当CPU分片轮询到它的时候,它就再次运行,直至 DEAD 状态。等待阻塞和锁阻塞其实是同一类型的,都是因为争夺锁而发生的线程等待,唯一的不同是因为它们调用的是不同的方式实现,但底层原理相同。要注意的是执行 wait() 方法的时候,线程一定要获得锁,所以 wait() 方法一般都在 synchronized 方法或代码块中。当其获得锁之后进入等待池(wait pool)并释放锁。收到 notify() 通知之后等待获取锁,获取锁之后才可以运行。

  • 发生其他阻塞(如:IO读取等)

当线程需要去读取文件,而此时文件又被其他线程占用,那么就会发生阻塞。这时候线程需要等待其他线程读取完之后才能继续进行,这可以称为 IO 阻塞。当然了还有很多种情况,如网络阻塞等等

CountDownLatch应用

CountDownLatch是一个同步辅助类,这个类能够使一个线程等待其他线程完成各自的工作后再执行。例如,应用程序的主线程希望在负责启动框架服务的线程已经启动所有的框架服务之后再执行。

CountDownLatch实现概要

CountDownLatch是通过一个计数器来实现的,计数器的初始值为线程的数量。每当一个线程完成了自己的任务后,计数器的值就会减1。当计数器值到达0时,它表示所有的线程已经完成了任务,然后在闭锁上等待的线程就可以恢复执行任务。

CountDownLatch多线程应用

列表循环遍历(耗时8697ms)

@Test
public void optimizeListV1(){
    long start = System.currentTimeMillis();
    try {
        final List<String> lists = Arrays.asList("aa", "bb", "cc", "dd", "ee");
        for(int i=0; i<lists.size(); i++){
            if(i == 2){
                Thread.sleep(3000);
            }
            Thread.sleep(1000);
        }
        System.out.println("聚合完成");
    }catch (Exception e){

    }finally {
        MockTimeUtil.mockInvokeTime("循环列表场景模拟:", start);
    }
}
复制代码

多线程聚合列表(耗时4671ms)

@Test
public void optimizeList(){
    long start = System.currentTimeMillis();
    try {
       ExecutorService ex = Executors.newFixedThreadPool(5);
       final List<String> lists = Arrays.asList("aa", "bb", "cc", "dd", "ee");
       final CountDownLatch latch =  new CountDownLatch(lists.size());
       for(int i=0; i<lists.size(); i++){
           final int tmp = i;
           ex.submit(new Callable<Object>() {
               @Override
               public Object call() throws Exception {
                   if(tmp == 2){
                       Thread.sleep(3000);
                   }
                   Thread.sleep(1000);
                   latch.countDown();
                   return null;
               }
           });
       }
       //latch.await();
       latch.await(3500, TimeUnit.MILLISECONDS);
       System.out.println("聚合完成");
   }catch (Exception e){

   }finally {
       MockTimeUtil.mockInvokeTime("线程列表场景模拟:", start);
   }
}
复制代码

CountDownLatch方法说明

CountDownLatch源码

public class CountDownLatch {
    /**
     * Synchronization control For CountDownLatch.
     * Uses AQS state to represent count.
     */
    private static final class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = 4982264981922014374L;

        Sync(int count) {
            setState(count);
        }

        int getCount() {
            return getState();
        }

        protected int tryAcquireShared(int acquires) {
            return (getState() == 0) ? 1 : -1;
        }

        protected boolean tryReleaseShared(int releases) {
            // Decrement count; signal when transition to zero
            for (;;) {
                int c = getState();
                if (c == 0)
                    return false;
                int nextc = c-1;
                if (compareAndSetState(c, nextc))
                    return nextc == 0;
            }
        }
    }

    private final Sync sync;

    public CountDownLatch(int count) {
        if (count < 0) throw new IllegalArgumentException("count < 0");
        this.sync = new Sync(count);
    }

    public void await() throws InterruptedException {
        sync.acquireSharedInterruptibly(1);
    }

    public boolean await(long timeout, TimeUnit unit)
        throws InterruptedException {
        return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
    }

    public void countDown() {
        sync.releaseShared(1);
    }

    public long getCount() {
        return sync.getCount();
    }

    public String toString() {
        return super.toString() + "[Count = " + sync.getCount() + "]";
    }
}
复制代码

方法说明:

  • countDown():当前线程调用此方法,则计数减一
  • await(): 调用此方法会一直阻塞当前线程,直到计时器的值为0

CompleteService应用

CompleteService使用场景

当我们需要批量任务处理,但是并不关心任务完成的先后顺序,我们异步的提交任务,等待有任务执行完成之后然后对该完成结果处理,如此循环直到该批量任务完成. 我们遵循异步处理完成后的操作原则时,谁先完成收割谁.

基于集合Future遍历处理

针对这种场景,我们很可能会想到把所有的异步任务收集到一个集合,然后遍历这个集合(Future),调用future.get()获取处理结果,进行后续操作,然后我们就会写出如下代码

ExecutorService pool = Executors.newFixedThreadPool(5);
final List<String> dList = Arrays.asList("aa", "bb", "cc", "dd", "ee");
List<Future> fList= new ArrayList<Future>();
for(int i=0; i<dList.size(); i++){
    final int tmp = i;
    Future future =  pool.submit(new Callable<Object>() {
        @Override
        public Object call() throws Exception {
            if (tmp == 2) {
                Thread.sleep(3000);
            }
            Thread.sleep(1000);
            return "线程" + Thread.currentThread().getName() + "处理数据元素list(" +  + tmp +") = " + dList.get(tmp) ;
        }
    });
    fList.add(future);
}
System.out.println("聚合完成");
for (int i = 0; i < fList.size(); i++) {
    System.out.println(fList.get(i).get());
}
复制代码

执行这段代码,会发现执行结果并没有按照我们所预期的来

聚合完成
线程pool-1-thread-1处理数据元素list(0) = aa
线程pool-1-thread-2处理数据元素list(1) = bb
线程pool-1-thread-3处理数据元素list(2) = cc
线程pool-1-thread-4处理数据元素list(3) = dd
线程pool-1-thread-5处理数据元素list(4) = ee
复制代码

可见,上面的执行结果并不是我们想要的,明显cc元素的执行比较耗时,但是我们的处理结果却是按照循环遍历的顺序来的,原因如下:

从list中遍历的每个Future对象并不一定处于完成状态,这时调用get()方法就会被阻塞住,如果系统是设计成每个线程完成后就能根据其结果继续做后面的事,这样对于处于list后面的但是先完成的线程就会增加了额外的等待时间。

基于CompletionService完成并行聚合

ExecutorService pool = Executors.newFixedThreadPool(5);
CompletionService<Object> cs = new ExecutorCompletionService<Object>(pool);
final List<String> dList = Arrays.asList("aa", "bb", "cc", "dd", "ee");
for(int i=0; i<dList.size(); i++){
    final int tmp = i;
    cs.submit(new Callable<Object>() {
        @Override
        public Object call() throws Exception {
            if (tmp == 2) {
                Thread.sleep(3000);
            }
            Thread.sleep(1000);
            return "线程" + Thread.currentThread().getName() + "处理数据元素list(" +  + tmp +") = " + dList.get(tmp);
        }
    });
}
System.out.println("聚合完成");
for (int i = 0; i < dList.size(); i++) {
    System.out.println(cs.take().get());
}
复制代码

执行会发现这种结果才是我们真正要的

聚合
完成
线程pool-1-thread-2处理数据元素list(1) = bb
线程pool-1-thread-1处理数据元素list(0) = aa
线程pool-1-thread-4处理数据元素list(3) = dd
线程pool-1-thread-5处理数据元素list(4) = ee
线程pool-1-thread-3处理数据元素list(2) = cc
复制代码

我们能得到想要的结果,是因为CompleteService中内部维护着一个BlockingQueue.原理如下:

CompletionService的实现是维护一个保存Future对象的BlockingQueue。只有当这个Future对象状态是结束的时候,才会加入到这个Queue中,take()方法其实就是Producer-Consumer中的Consumer。它会从Queue中取出Future对象,如果Queue是空的,就会阻塞在那里,直到有完成的Future对象加入到Queue中。

CompleteService中take()和poll()的区别

查看CompleteService的接口定义

public interface CompletionService<V> {

    Future<V> submit(Callable<V> task);

    Future<V> submit(Runnable task, V result);

    Future<V> take() throws InterruptedException;

    Future<V> poll();

    Future<V> poll(long timeout, TimeUnit unit) throws InterruptedException;
}
复制代码

从接口中我们可以看到CompletionService中定义的summit相关的方法用来载入线程体(分别处理实现Callable或Runable的线程), poll()和take()用来获取返回结果集.

关于poll()和take()的区别

poll()是非阻塞的,若目前无结果,返回一个null,线程继续运行不阻塞。take()是阻塞的,若当前无结果,则线程阻塞,直到产生一个结果

示例poll()和take()的区别
ExecutorService pool = Executors.newFixedThreadPool(5);
CompletionService<Object> cs = new ExecutorCompletionService<Object>(pool);
final List<String> dList = Arrays.asList("aa", "bb", "cc", "dd", "ee");
for(int i=0; i<dList.size(); i++){
    final int tmp = i;
    cs.submit(new Callable<Object>() {
        @Override
        public Object call() throws Exception {
            if (tmp == 2) {
                Thread.sleep(3000);
            }
            Thread.sleep(1000);
            return "线程" + Thread.currentThread().getName() + "处理数据元素list(" +  + tmp +") = " + dList.get(tmp);
        }
    });
}
System.out.println("聚合完成");
AtomicInteger index = new AtomicInteger(0);
while(index.get()<dList.size()) {
    Future<Object> f = cs.poll();
    if(f == null) {
        System.out.println("没发现有完成的任务");
    }else {
        System.out.println(f.get());
        index.incrementAndGet();
    }
    Thread.sleep(500);
}
复制代码

程序运行结果

聚合完成
没发现有完成的任务
没发现有完成的任务
线程pool-1-thread-1处理数据元素list(0) = aa
线程pool-1-thread-4处理数据元素list(3) = dd
线程pool-1-thread-2处理数据元素list(1) = bb
线程pool-1-thread-5处理数据元素list(4) = ee
没发现有完成的任务
没发现有完成的任务
线程pool-1-thread-3处理数据元素list(2) = cc
复制代码

JavaIO模型

JavaIO模型

在高性能的IO体系设计中,有几个名词概念需要先做个概述.

同步和异步、阻塞和非阻塞

同步和异步是针对应用程序与内核的交互而言的,阻塞和非阻塞是针对于进程在访问数据的时候,根据IO操作的就绪状态来采取的不同方式,说白了是一种读取或者写入操作函数的实现方式,阻塞方式下读取或者写入函数将一直等待,而非阻塞方式下,读取或者写入函数会立即返回一个状态值. 简而言之: 同步和异步是目的,阻塞和非阻塞是实现方式。

  • 同步: 指的是用户进程触发IO操作并等待或者轮询的去查看IO操作是否就绪.
  • 异步: 指用户进程触发IO操作以后便开始做自己的事情,而当IO操作已经完成的时候会得到IO完成的通知(异步的特点就是通知)告诉朋友自己合适衣服的尺寸,大小,颜色,让朋友委托去卖,然后自己可以去干别的事.
  • 阻塞: 所谓阻塞方式的意思是指, 当试图对该文件描述符进行读写时, 如果当时没有东西可读,或者暂时不可写, 程序就进入等待 状态, 直到有东西可读或者可写为止. 比如:去公交站充值,发现这个时候,充值员不在(可能上厕所去了),然后我们就在这里等待,一直等到充值员回来为止.
  • 非阻塞: 非阻塞状态下,如果没有东西可读或者不可写, 读写函数马上返回而不会等待. 比如: 银行里取款办业务时,领取一张小票,领取完后我们自己可以玩玩手机,或者与别人聊聊天,当轮我们时,银行的喇叭会通知,这时候我们就可以去了.

Java中的IO模型

  • Java中主要的三种IO模型: 阻塞IO(BIO)、非阻塞IO(NIO)和 异步IO(AIO).

  • Java中提供的IO有关的API,在文件处理的时候,其实依赖操作系统层面的IO操作实现的。比如在Linux 2.6以后,Java中NIO和AIO都是通过epoll来实现的,而在Windows上,AIO是通过IOCP来实现的.

同步阻塞IO(JAVA BIO)

同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善.

同步非阻塞IO(Java NIO)

同步非阻塞,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。用户进程也需要时不时的询问IO操作是否就绪,这就要求用户进程不停的去询问.

异步阻塞IO(Java NIO)

此种方式下是指应用发起一个IO操作以后,不等待内核IO操作的完成,等内核完成IO操作以后会通知应用程序,这其实就是同步和异步最关键的区别,同步必须等待或者主动的去询问IO是否完成,那么为什么说是阻塞的呢?因为此时是通过select系统调用来完成的,而select函数本身的实现方式是阻塞的,而采用select函数有个好处就是它可以同时监听多个文件句柄(如果从UNP的角度看,select属于同步操作。因为select之后,进程还需要读写数据)从而提高系统的并发性!

异步非阻塞IO(Java AIO(NIO.2))

在此种模式下,用户进程只需要发起一个IO操作然后立即返回,等IO操作真正的完成以后,应用程序会得到IO操作完成的通知,此时用户进程只需要对数据进行处理就好了,不需要进行实际的IO读写操作,因为真正的IO读取或者写入操作已经由内核完成了.

BIO、NIO、AIO应用场景

  • BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解.
  • NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4开始支持.
  • AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持.

Linux中的IO模型

  • 在Linux(UNIX)中共有五种IO模型: 阻塞IO模型、非阻塞IO模型、信号驱动IO模型、IO复用模型 和 异步IO模型.

阻塞IO模型

阻塞 I/O 是最简单的 I/O 模型,一般表现为进程或线程等待某个条件,如果条件不满足,则一直等下去。条件满足,则进行下一步操作.

应用进程通过系统调用 recvfrom 接收数据,但由于内核还未准备好数据报,应用进程就会阻塞住,直到内核准备好数据报,recvfrom 完成数据报复制工作,应用进程才能结束阻塞状态。

非阻塞IO模型

非阻塞的IO模型。应用进程与内核交互,目的未达到之前,不再一味的等着,而是直接返回。然后通过轮询的方式,不停的去问内核数据准备有没有准备好。如果某一次轮询发现数据已经准备好了,那就把数据拷贝到用户空间中。

应用进程通过 recvfrom 调用不停的去和内核交互,直到内核准备好数据。如果没有准备好,内核会返回error,应用进程在得到error后,过一段时间再发送recvfrom请求。在两次发送请求的时间段,进程可以先做别的事情.

信号驱动IO模型

应用进程在读取文件时通知内核,如果某个 socket 的某个事件发生时,请向我发一个信号。在收到信号后,信号对应的处理函数会进行后续处理。

应用进程预先向内核注册一个信号处理函数,然后用户进程返回,并且不阻塞,当内核数据准备就绪时会发送一个信号给进程,用户进程便在信号处理函数中开始把数据拷贝的用户空间中。

IO复用模型

多个进程的IO可以注册到同一个管道上,这个管道会统一和内核进行交互。当管道中的某一个请求需要的数据准备好之后,进程再把对应的数据拷贝到用户空间中。

IO多路转接是多了一个select函数,多个进程的IO可以注册到同一个select上,当用户进程调用该select,select会监听所有注册好的IO,如果所有被监听的IO需要的数据都没有准备好时,select调用进程会阻塞。当任意一个IO所需的数据准备好之后,select调用就会返回,然后进程在通过recvfrom来进行数据拷贝。

这里的IO复用模型,并没有向内核注册信号处理函数,所以,他并不是非阻塞的。进程在发出select后,要等到select监听的所有IO操作中至少有一个需要的数据准备好,才会有返回,并且也需要再次发送请求去进行文件的拷贝。

异步IO模型

异步IO模型。应用进程把IO请求传给内核后,完全由内核去操作文件拷贝。内核完成相关操作后,会发信号告诉应用进程本次IO已经完成.

用户进程发起aio_read操作之后,给内核传递描述符、缓冲区指针、缓冲区大小等,告诉内核当整个操作完成时,如何通知进程,然后就立刻去做其他事情了。当内核收到aio_read后,会立刻返回,然后内核开始等待数据准备,数据准备好以后,直接把数据拷贝到用户控件,然后再通知进程本次IO已经完成。

5种IO模型对比

说明:

  • 阻塞IO模型、非阻塞IO模型、IO复用模型和信号驱动IO模型都是同步的IO模型。原因是因为,无论以上那种模型,真正的数据拷贝过程,都是同步进行的。
  • 虽然信号驱动IO模型,内核是在数据准备好之后通知进程,然后进程再通过recvfrom操作进行数据拷贝。我们可以认为数据准备阶段是异步的,但是,数据拷贝操作是同步的。所以,整个IO过程也不能认为是异步的。

参考

猜你喜欢

转载自juejin.im/post/5dfed5fc518825123b1a9de0
今日推荐