Java基础
Java 概述
何为编程
编程就是让计算机为解决某个问题而使用某种程序设计语言编写程序代码,并 终得到结果的过程。 为了使计算机能够理解人的意图,人类就必须要将需解决的问题的思路、方法、 和手段通过计算机能够 理解的形式告诉计算机,使得计算机能够根据人的指令一 步一步去工作,完成某种特定的任务。这种人 和计算机之间交流的过程就是编 程。
什么是 Java
Java 是一门面向对象编程语言,不仅吸收了 C++语言的各种优点,还摒弃了 C++里难以理解的多继承、 指针等概念,因此 Java 语言具有功能强大和简单易 用两个特征。Java 语言作为静态面向对象编程语言的 代表,极好地实现了面向对 象理论,允许程序员以优雅的思维方式进行复杂的编程 。
jdk1.5 之后的三大版本
- Java SE(J2SE,Java 2 Platform Standard Edition,标准版) Java SE 以前称为 J2SE。它允许开发 和部署在桌面、服务器、嵌入式环境和实时环境中使 用的 Java 应用程序。Java SE 包含了支持 Java Web 服务开发的类,并为 Java EE 和 Java ME 提供基础。
- Java EE(J2EE,Java 2 Platform Enterprise Edition,企业版) Java EE 以前称为 J2EE。企业版本 帮助开发和部署可移植、健壮、可伸缩且安全的服务器 端 Java 应用程序。Java EE 是在 Java SE 的 基础上构建的,它提供 Web 服务、组件模型、 管理和通信 API,可以用来实现企业级的面向服务 体系结构(service-oriented architecture,SOA)和 Web2.0 应用程序。2018 年 2 月,Eclipse 宣 布正式将 JavaEE 更名 为 JakartaEE
- Java ME(J2ME,Java 2 Platform Micro Edition,微型版) Java ME 以前称为 J2ME。Java ME 为 在移动设备和嵌入式设备(比如手机、PDA、电视 机顶盒和打印机)上运行的应用程序提供一个健 壮且灵活的环境。Java ME 包括灵活的用 户界面、健壮的安全模型、许多内置的网络协议以及对可 以动态下载的连网和离线应用程序 的丰富支持。基于 Java ME 规范的应用程序只需编写一次,就 可以用于许多设备,而且可 以利用每个设备的本机功能。
JVM、JRE 和 JDK 的关系
- JVM Java Virtual Machine 是 Java 虚拟机,Java 程序需要运行在虚拟机上,不同的平 台有自己的虚拟机,因此 Java 语言可以实现跨平台。
- JRE Java Runtime Environment 包括 Java 虚拟机和 Java 程序所需的核心类库等。核 心类库主要是 java.lang 包:包含了运行 Java 程序必不可少的系统类,如基本数 据类型、基本数学函数、字符串处理、线程、异 常处理类等,系统缺省加载这个包 如果想要运行一个开发好的 Java 程序,计算机中只需要安装 JRE 即可。
- JDK Java Development Kit 是提供给 Java 开发人员使用的,其中包含了 Java 的开发 工具,也包括了 JRE。所以 安装了 JDK,就无需再单独安装 JRE 了。其中的开发工 具:编译工具(javac.exe),打包工具(jar.exe)等
什么是跨平台性?原理是什么
所谓跨平台性,是指 java 语言编写的程序,一次编译后,可以在多个系统平台上 运行。
实现原理:Java 程序是通过 java 虚拟机在系统平台上运行的,只要该系统可以安 装相应的 java 虚拟机, 该系统就可以运行 java 程序。
Java 语言有哪些特点
- 简单易学(Java 语言的语法与 C 语言和 C++语言很接近)
- 面向对象(封装,继承,多态)
- 平台无关性(Java 虚拟机实现平台无关性)
- 支持网络编程并且很方便(Java 语言诞生本身就是为简化网络编程设计的)
- 支持多线程(多线程机制使应用程序在同一时间并行执行多项任)
- 健壮性(Java 语言的强类型机制、异常处理、垃圾的自动收集等)
- 安全性
什么是字节码?采用字节码的大好处是什么
字节码:Java 源代码经过虚拟机编译器编译后产生的文件(即扩展为.class 的文 件),它不面向任何特 定的处理器,只面向虚拟机。
采用字节码的好处:
Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的 问题,同时又保留了解 释型语言可移植的特点。所以 Java 程序运行时比较高效, 而且,由于字节码并不专对一种特定的机器, 因此,Java 程序无须重新编译便可 在多种不同的计算机上运行。
先看下 java 中的编译器和解释器:
Java 中引入了虚拟机的概念,即在机器和编译程序之间加入了一层抽象的虚拟机 器。这台虚拟的机器在 任何平台上都提供给编译程序一个的共同的接口。编译程 序只需要面向虚拟机,生成虚拟机能够理解的 代码,然后由解释器来将虚拟机代 码转换为特定系统的机器码执行。在 Java 中,这种供虚拟机理解的代 码叫做字节 码(即扩展为.class 的文件),它不面向任何特定的处理器,只面向虚拟机。每 一种平台的 解释器是不同的,但是实现的虚拟机是相同的。Java 源程序经过编译 器编译后变成字节码,字节码由虚 拟机解释执行,虚拟机将每一条要执行的字节 码送给解释器,解释器将其翻译成特定机器上的机器码, 然后在特定的机器上运 行,这就是上面提到的 Java 的特点的编译与解释并存的解释。
什么是 Java 程序的主类?应用程序和小程序的主类有何不同?
一个程序中可以有多个类,但只能有一个类是主类。在 Java 应用程序中,这个主 类是指包含 main()方法 的类。而在 Java 小程序中,这个主类是一个继承自系统 类 JApplet 或 Applet 的子类。应用程序的主类不一 定要求是 public 类,但小程序 的主类要求必须是 public 类。主类是 Java 程序执行的入口点。
Java 应用程序与小程序之间有那些差别?
简单说应用程序是从主线程启动(也就是 main()方法)。applet 小程序没有 main 方法,主要是嵌在浏览器 页面上运行(调用 init()线程或者 run()来启动),嵌入浏 览器这点跟 flash 的小游戏类似。
Java 和 C++的区别
我知道很多人没学过 C++,但是面试官就是没事喜欢拿咱们 Java 和 C++比呀! 没办法!!!就算没学过 C++,也要记下来!
- 都是面向对象的语言,都支持封装、继承和多态
- Java 不提供指针来直接访问内存,程序内存更加安全
- Java 的类是单继承的,C++支持多重继承;虽然 Java 的类不可以多继承,但是 接口可以多继承。
- Java 有自动内存管理机制,不需要程序员手动释放无用内存
Oracle JDK 和 OpenJDK 的对比
- Oracle JDK 版本将每三年发布一次,而 OpenJDK 版本每三个月发布一 次;
- OpenJDK 是一个参考模型并且是完全开源的,而 Oracle JDK 是 OpenJDK 的一个实现,并不是完全 开源的;
- Oracle JDK 比 OpenJDK 更稳定。OpenJDK 和 Oracle JDK 的代码几乎 相同,但 Oracle JDK 有更多的 类和一些错误修复。因此,如果您想开发企 业/商业软件,我建议您选择 Oracle JDK,因为它经过 了彻底的测试和稳 定。某些情况下,有些人提到在使用 OpenJDK 可能会遇到了许多应用程 序崩溃 的问题,但是,只需切换到 Oracle JDK 就可以解决问题;
- 在响应性和 JVM 性能方面,Oracle JDK 与 OpenJDK 相比提供了更好的 性能;
- Oracle JDK 不会为即将发布的版本提供长期支持,用户每次都必须通过 更新到最新版本获得支持来 获取最新版本;
- Oracle JDK 根据二进制代码许可协议获得许可,而 OpenJDK 根据 GPL v2 许可获得许可。
基础语法
数据类型
Java 有哪些数据类型
定义:Java 语言是强类型语言,对于每一种数据都定义了明确的具体的数据类 型,在内存中分配了不同 大小的内存空间。
分类:
基本数据类型
- 数值型
- 整数类型(byte,short,int,long)
- 浮点类型(float,double)
- 字符型(char)
- 布尔型(boolean)
引用数据类型
- 类(class)
- 接口(interface)
- 数组([])
switch 是否能作用在 byte 上,是否能作用在 long 上,是否 能作用在 String 上
在 Java 5 以前,switch(expr)中,expr 只能是 byte、short、char、int。从 Java5 开始,Java 中引入 了枚举类型,expr 也可以是 enum 类型,从 Java 7 开始,expr 还可以是字符串(String),但是长整 型(long)在目前所有的版 本中都是不可以的
用最有效率的方法计算 2 乘以 8
2 << 3(左移 3 位相当于乘以 2 的 3 次方,右移 3 位相当于除以 2 的 3 次 方)。
Math.round(11.5) 等于多少?Math.round(-11.5) 等于多少
Math.round(11.5)的返回值是 12,Math.round(-11.5)的返回值是-11。四舍 五入的原理是在参数上加 0.5 然后进行下取整。
loat f=3.4;是否正确
不正确。3.4 是双精度数,将双精度型(double)赋值给浮点型(float)属于 下转型(down-casting, 也称为窄化)会造成精度损失,因此需要强制类型转 换 float f =(float)3.4; 或者写成 float f =3.4F;。
short s1 = 1; s1 = s1 + 1;有错吗?short s1 = 1; s1 += 1;有错吗
对于 short s1 = 1; s1 = s1 + 1;由于 1 是 int 类型,因此 s1+1 运算结果也是 int 型,需要强制转换类型才 能赋值给 short 型。 而 short s1 = 1; s1 += 1;可以正确编译,因为 s1+= 1;相当于 s1 = short(s1 + 1);其 中有隐含的强制类型转换。
编码
Java 语言采用何种编码方案?有何特点?
Java 语言采用 Unicode 编码标准,Unicode(标准码),它为每个字符制订了一 个唯一的数值,因此在 任何的语言,平台,程序都可以放心的使用。
注释
什么 Java 注释
定义:用于解释说明程序的文字
分类
- 单行注释 格式: // 注释文字
- 多行注释 格式: /* 注释文字 */
- 文档注释 格式:/** 注释文字 */
作用
在程序中,尤其是复杂的程序中,适当地加入注释可以增加程序的可读性,有利 于程序的修改、调试和 交流。注释的内容在程序编译的时候会被忽视,不会产生 目标代码,注释的部分不会对程序的执行结果 产生任何影响。 注意事项:多行和文档注释都不能嵌套使用。 访问修饰符
访问修饰符 public,private,protected,以及不写(默认)时的 区别
定义:Java 中,可以使用访问修饰符来保护对类、变量、方法和构造方法的访 问。Java 支持 4 种不同的 访问权限。
分类:
- private : 在同一类内可见。使用对象:变量、方法。 注意:不能修饰类(外部 类)
- default (即缺省,什么也不写,不使用任何关键字): 在同一包内可见,不使用 任何修饰符。使用 对象:类、接口、变量、方法。
- protected : 对同一包内的类和所有子类可见。使用对象:变量、方法。 注意: 不能修饰类(外部 类)。
- public : 对所有类可见。使用对象:类、接口、变量、方法 访问修饰符图
运算符
&和&&的区别
&运算符有两种用法:(1)按位与;(2)逻辑与。
&&运算符是短路与运算。逻辑与跟短路与的差别是非常巨大的,虽然二者都要 求运算符左右两端的布 尔值都是 true 整个表达式的值才是 true。&&之所以称 为短路运算,是因为如果&&左边的表达式的值是 false,右边的表达式会被直 接短路掉,不会进行运 算
注意:逻辑或运算符(|)和短路或运算符(||)的差别也是如此。
关键字
Java 有没有 goto
goto 是 Java 中的保留字,在目前版本的 Java 中没有使用。
final 有什么用?
用于修饰类、属性和方法;
- 被 final 修饰的类不可以被继承
- 被 final 修饰的方法不可以被重写
- 被 final 修饰的变量不可以被改变,被 final 修饰不可变的是变量的引用,而不是引用指向的内容,引 用指向的内容是可以改变的
final finally finalize 区别
- final 可以修饰类、变量、方法,修饰类表示该类不能被继承、修饰方法表示该方法不能被重写、修 饰变量表示该变量是一个常量不能被重新赋值。
- finally 一般作用在 try-catch 代码块中,在处理异常的时候,通常我们将一定要执行的代码方法 finally 代码块中,表示不管是否出现异常,该代码块都会执行,一般用来存放一些关闭资源的代 码。
- finalize 是一个方法,属于 Object 类的一个方法,而 Object 类是所有类的父类,该方法一般由垃圾 回收器来调用,当我们调用 System.gc() 方法的时候,由垃圾回收器调用 finalize(),回收垃圾,一 个对象是否可回收的最后判断。
this 关键字的用法
this 是自身的一个对象,代表对象本身,可以理解为:指向对象本身的一个指 针。 this 的用法在 java 中大体可以分为 3 种:
- 普通的直接引用,this 相当于是指向当前对象本身。
- 形参与成员名字重名,用 this 来区分:
1 public Person(String name, int age) {
2 this.name = name;
3 this.age = age;
4 }
3.引用本类的构造函数
1 class Person{
2 private String name;
3 private int age;
4
5 public Person() {
6 }
7
8 public Person(String name) {
9 this.name = name;
10 }
11 public Person(String name, int age){
12 this(name);
13 this.age = age;
14 }
15 }
super 关键字的用法
super 可以理解为是指向自己超(父)类对象的一个指针,而这个超类指的是离 自己最近的一个父类。
super 也有三种用法:
- 普通的直接引用 与 this 类似,super 相当于是指向当前对象的父类的引用,这样就可以用 super.xxx 来引用父类的成员。
- 子类中的成员变量或方法与父类中的成员变量或方法同名时,用 super 进行区 分
1 class Person{ 2 protected String name; 3 4 public Person(String name) { 5 this.name = name; 6 } 7 8 } 9 1 0 class Student extends Person{ 11 private String name; 12 13 public Student(String name, String name1) { 14 super(name); 15 this.name = name1; 16 } 17 18 public void getInfo(){ 19 System.out.println(this.name); //Child 20 System.out.println(super.name); //Father 21 } 22 23 } 24 25 public class Test { 26 public static void main(String[] args) { 27 Student s1 = new Student("Father","Child"); 28 s1.getInfo(); 29 30 } 31 }
- 引用父类构造函数
- super(参数):调用父类中的某一个构造函数(应该为构造函数中的第一条语句)。
- this(参数):调用本类中另一种形式的构造函数(应该为构造函数中的第一条语句)。
- 引用父类构造函数
this 与 super 的区别
- super: 它引用当前对象的直接父类中的成员(用来访问直接父类中被隐藏的父类中成员数据或函 数,基类与派生类中有相同成员定义时如:super.变量名 super.成员函数据名(实参)
- this:它代表当前对象名(在程序中易产生二义性之处,应使用 this 来指明当前对象;如果函数的 形参与类中的成员数据同名,这时需用 this 来指明成员变量名)
- super()和 this()类似,区别是,super()在子类中调用父类的构造方法,this()在本类内调用本类的其 它构造方法。
- super()和 this()均需放在构造方法内第一行。
- 尽管可以用 this 调用一个构造器,但却不能调用两个。
- this 和 super 不能同时出现在一个构造函数里面,因为 this 必然会调用其它的构造函数,其它的构造 函数必然也会有 super 语句的存在,所以在同一个构造函数里面有相同的语句,就失去了语句的意 义,编译器也不会通过。
- this()和 super()都指的是对象,所以,均不可以在 static 环境中使用。包括: static 变量,static 方法,static 语句块。
- 从本质上讲,this 是一个指向本对象的指针, 然而 super 是一个 Java 关键字。
static 存在的主要意义
static 的主要意义是在于创建独立于具体对象的域变量或者方法。以致于即使没有创建对象,也能使用属 性和调用方法!
static 关键字还有一个比较关键的作用就是 用来形成静态代码块以优化程序性能。static 块可以置于类中 的任何地方,类中可以有多个 static 块。在类初次被加载的时候,会按照 static 块的顺序来执行每个 static 块,并且只会执行一次。为什么说 static 块可以用来优化程序性能,是因为它的特性:只会在类加载的时 候执行一次。因此,很多时候会将一些只需要进行一次的初始化操作都放在 static 代码块中进行。
static 的独特之处
- 1、被 static 修饰的变量或者方法是独立于该类的任何对象,也就是说,这些变量和方法不属于任何一个 实例对象,而是被类的实例对象所共享。
怎么理解 “被类的实例对象所共享” 这句话呢?就是说,一个类的静态成员,它是属于大伙的【大伙指的是这个类的多个对象实例,我们都知道一个类可以创建多个实例!】,所有的类对象共享的,不像成员变量是自个的【自个指的是这个类的单个实例对象】…我觉得我已经讲的很通俗了,你明白了咩?
- 2、在该类被第一次加载的时候,就会去加载被 static 修饰的部分,而且只在类第一次使用时加载并进行 初始化,注意这是第一次用就要初始化,后面根据需要是可以再次赋值的。
- 3、static 变量值在类加载的时候分配空间,以后创建类对象的时候不会重新分配。赋值的话,是可以任 意赋值的!
- 4、被 static 修饰的变量或者方法是优先于对象存在的,也就是说当一个类加载完毕之后,即便没有创建 对象,也可以去访问。
static 应用场景
因为 static 是被类的实例对象所共享,因此如果某个成员变量是被所有对象所共享的,那么这个成员变量 就应该定义为静态变量。 因此比较常见的 static 应用场景有:
- 1、修饰成员变量
- 2、修饰成员方法
- 3、静态代码块
- 4、修饰类【只能修饰内部类也就是静态内部类】
- 5、静态导包
static 注意事项
1、静态只能访问静态。 2、非静态既可以访问非静态的,也可以访问静态的。
流程控制语句
break ,continue ,return 的区别及作用
- break 跳出总上一层循环,不再执行循环(结束当前的循环体)
- continue 跳出本次循环,继续执行下次循环(结束正在执行的循环 进入下一个循环条件)
- return 程序返回,不再执行下面的代码(结束当前的方法 直接返回)
在 Java 中,如何跳出当前的多重嵌套循环
在 Java 中,要想跳出多重循环,可以在外面的循环语句前定义一个标号,然后在里层循环体的代码中使 用带有标号的 break 语句,即可跳出外层循环。例如:
1 public static void main(String[] args) {
2 ok:
3 for (int i = 0; i < 10; i++) {
4 for (int j = 0; j < 10; j++) {
5 System.out.println("i=" + i + ",j=" + j);
6 if (j == 5) {
7 break ok;
8 }
9 1
0 }
11 }
12 }
面向对象
面向对象概述
面向对象和面向过程的区别
面向过程:
优点:性能比面向对象高,因为类调用时需要实例化,开销比较大,比较消耗资源;比如单片机、嵌入式 开发、Linux/Unix 等一般采用面向过程开发,能是最重要的因素。
缺点:没有面向对象易维护、易复用、易扩展
面向对象:
优点:易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特性,可以设计出低耦合的系 统,使系统更加灵活、更加易于维护
缺点:性能比面向过程低 面向过程是具体化的,流程化的,解决一个问题,你需要一步一步的分析,一步 一步的实现。
面向对象是模型化的,你只需抽象出一个类,这是一个封闭的盒子,在这里你拥有数据也拥有解决问题 的方法。需要什么功能直接使用就可以了,不必去一步一步的实现,至于这个功能是如何实现的,管我 们什么事?我们会用就可以了。
面向对象的底层其实还是面向过程,把面向过程抽象成类,然后封装,方便我们使用的就是面向对象 了
面向对象三大特性
面向对象的特征有哪些方面
面向对象的特征主要有以下几个方面:
抽象:抽象是将一类对象的共同特征总结出来构造类的过程,包括数据抽象和行 为抽象两方面。抽象只 关注对象有哪些属性和行为,并不关注这些行为的细节是 什么。
封装 : 封装把一个对象的属性私有化,同时提供一些可以被外界访问的属性的方法,如 果属性不想 被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没 有提供给外界访问的方法,那 么这个类也没有什么意义了。
继承 : 继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新 的数据或新 的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用 继承我们能够非常方便地复用 以前的代码。
关于继承如下 3 点请记住:
- 1.子类拥有父类非 private 的属性和方法。
- 2.子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
- 3.子类可以用自己的方式实现父类的方法。
多态 : 所谓多态就是指程序中定义的引用变量所指向的具体类型和通过该引用变量发出 的方法调用 在编程时并不确定,而是在程序运行期间才确定,即一个引用变量到 底会指向哪个类的实例对象, 该引用变量发出的方法调用到底是哪个类中实现的 方法,必须在由程序运行期间才能决定。 在 Java 中有两种形式可以实现多态:继承(多个子类对同一方法的重写)和接口 (实现接口并覆盖 接口中同一方法)。
其中 Java 面向对象编程三大特性:封装 继承 多态
封装:隐藏对象的属性和实现细节,仅对外提供公共访问方式,将变化隔离,便 于使用,提高复用性和 安全性。
继承:继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以 增加新的数据或新的功 能,也可以用父类的功能,但不能选择性地继承父类。通 过使用继承可以提高代码复用性。继承是多态 的前提。
关于继承如下 3 点请记住:
- 子类拥有父类非 private 的属性和方法。
- 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。
多态性:父类或接口定义的引用变量可以指向子类或具体实现类的实例对象。提 高了程序的拓展性。
在 Java 中有两种形式可以实现多态:继承(多个子类对同一方法的重写)和接口 (实现接口并覆盖接口 中同一方法)。
方法重载(overload)实现的是编译时的多态性(也称为前绑定),而方法重 写(override)实现的是 运行时的多态性(也称为后绑定)。
一个引用变量到底会指向哪个类的实例对象,该引用变量发出的方法调用到底是 哪个类中实现的方法, 必须在由程序运行期间才能决定。运行时的多态是面向对 象精髓的东西,要实现多态需要做两件事: 方法重写(子类继承父类并重写父类中已有的或抽象的方法);
对象造型(用父类型引用子类型对象,这样同样的引用调用同样的方法就会根据 子类对象的不同而 表现出不同的行为)。
什么是多态机制?Java 语言是如何实现多态的?
所谓多态就是指程序中定义的引用变量所指向的具体类型和通过该引用变量发出 的方法调用在编程时并 不确定,而是在程序运行期间才确定,即一个引用变量倒 底会指向哪个类的实例对象,该引用变量发出 的方法调用到底是哪个类中实现的 方法,必须在由程序运行期间才能决定。因为在程序运行时才确定具 体的类,这 样,不用修改源程序代码,就可以让引用变量绑定到各种不同的类实现上,从而 导致该引用 调用的具体方法随之改变,即不修改程序代码就可以改变程序运行时 所绑定的具体代码,让程序可以选 择多个运行状态,这就是多态性。 多态分为编译时多态和运行时多态。其中编辑时多态是静态的,主要 是指方法的 重载,它是根据参数列表的不同来区分不同的函数,通过编辑之后会变成两个不 同的函数, 在运行时谈不上多态。而运行时多态是动态的,它是通过动态绑定来 实现的,也就是我们所说的多态性
多态的实现
Java 实现多态有三个必要条件:继承、重写、向上转型。
继承:在多态中必须存在有继承关系的子类和父类。
重写:子类对父类中某些方法进行重新定义,在调用这些方法时就会调用子类的 方法。
向上转型:在多态中需要将子类的引用赋给父类对象,只有这样该引用才能够具 备技能调用父类的方法 和子类的方法。
只有满足了上述三个条件,我们才能够在同一个继承结构中使用统一的逻辑实现 代码处理不同的对象, 从而达到执行不同的行为。
对于 Java 而言,它多态的实现机制遵循一个原则:当超类对象引用变量引用子类 对象时,被引用对象的 类型而不是引用变量的类型决定了调用谁的成员方法,但 是这个被调用的方法必须是在超类中定义过 的,也就是说被子类覆盖的方法。
面向对象五大基本原则是什么(可选)
单一职责原则 SRP(Single Responsibility Principle)类的功能要单一,不能包罗万象,跟杂货铺似 的。
开放封闭原则 OCP(Open-Close Principle) 一个模块对于拓展是开放的,对于修改是封闭的,想要增加功能热烈欢迎,想要修改,哼, 一万个不乐意。
里式替换原则 LSP(the Liskov Substitution Principle LSP)子类可以替换父类出现在父类能够出现的 任何地方。比如你能代表你爸去你姥姥家干活。哈哈~~
依赖倒置原则 DIP(the Dependency Inversion Principle DIP)高层次的模块不应该依赖于低层次的 模块,他们都应该依赖于抽象。抽象不应该依赖于具体实现,具体实现应该依赖于抽象。就是你出 国要说你是中国人,而不能说你是哪个村子的。比如说中国人是抽象的,下面有具体的 xx 省,xx 市,xx 县。你要依赖的抽象是中国人,而不是你是 xx 村的。
接口分离原则 ISP(the Interface Segregation Principle ISP) 设计时采用多个与特定客户类有关的接口比采用一个通用的接口要好。就比如一个手机拥有 打电话,看视频,玩游戏等功能,把这几个功能拆分成不同的接口,比在一个接口里要好的 多
类与接口
抽象类和接口的对比
抽象类是用来捕捉子类的通用特性的。接口是抽象方法的集合。
从设计层面来说,抽象类是对类的抽象,是一种模板设计,接口是行为的抽象,是一种行为的规范。
相同点
- 接口和抽象类都不能实例化
- 都位于继承的顶端,用于被其他实现或继承
- 都包含抽象方法,其子类都必须覆写这些抽象方法
不同点
备注:Java8 中接口中引入默认方法和静态方法,以此来减少抽象类和接口之间 的差异。
现在,我们可以为接口提供默认实现的方法了,并且不用强制子类来实现它。 接口和抽象类各有优缺 点,在接口和抽象类的选择上,必须遵守这样一个原则:
- 行为模型应该总是通过接口而不是抽象类定义,所以通常是优先选用接口,尽量 少用抽象类。
- 选择抽象类的时候通常是如下情况:需要定义子类的行为,又要为子类提供通用 的功能。
普通类和抽象类有哪些区别?
- 普通类不能包含抽象方法,抽象类可以包含抽象方法。
- 抽象类不能直接实例化,普通类可以直接实例化。
抽象类能使用 final 修饰吗?
不能,定义抽象类就是让其他类继承的,如果定义为 final 该类就不能被继承, 这样彼此就会产生矛 盾,所以 final 不能修饰抽象类
创建一个对象用什么关键字?对象实例与对象引用有何不同?
new 关键字,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实 例(对象引用存放在栈 内存中)。一个对象引用可以指向 0 个或 1 个对象(一根 绳子可以不系气球,也可以系一个气球);一个 对象可以有 n 个引用指向它(可以 用 n 条绳子系住一个气球)
变量与方法
成员变量与局部变量的区别有哪些
变量:在程序执行的过程中,在某个范围内其值可以发生改变的量。从本质上 讲,变量其实是内存中的 一小块区域 成员变量:方法外部,类内部定义的变量 局部变量:类的方法中的变量。
成员变量和局部变量的区别
- 作用域
- 成员变量:针对整个类有效。
- 局部变量:只在某个范围内有效。(一般指的就是方法,语句体内)
- 存储位置
- 成员变量:随着对象的创建而存在,随着对象的消失而消失,存储在堆内存中。
- 局部变量:在方法被调用,或者语句被执行的时候存在,存储在栈内存中。当方法调用完,或者语句结 束后,就自动释放。
- 生命周期
- 成员变量:随着对象的创建而存在,随着对象的消失而消失
- 局部变量:当方法调用完,或者语句结束后,就自动释放。
- 初始值
- 成员变量:有默认初始值。
- 局部变量:没有默认初始值,使用前必须赋值。
- 使用原则
- 在使用变量时需要遵循的原则为:就近原则 首先在局部范围找,有就使用;接着在成员位置找。
在 Java 中定义一个不做事且没有参数的构造方法的作用
Java 程序在执行子类的构造方法之前,如果没有用 super()来调用父类特定的构 造方法,则会调用父类中 “没有参数的构造方法”。因此,如果父类中只定义了 有参数的构造方法,而在子类的构造方法中又没有 用 super()来调用父类中特定 的构造方法,则编译时将发生错误,因为 Java 程序在父类中找不到没有参数 的构 造方法可供执行。解决办法是在父类里加上一个不做事且没有参数的构造方法。
在调用子类构造方法之前会先调用父类没有参数的构造方法,其 目的是?
帮助子类做初始化工作。
一个类的构造方法的作用是什么?若一个类没有声明构造方法, 改程序能正确执行吗?为什么?
主要作用是完成对类对象的初始化工作。可以执行。因为一个类即使没有声明构 造方法也会有默认的不 带参数的构造方法。
构造方法有哪些特性?
名字与类名相同;
没有返回值,但不能用 void 声明构造函数;
生成类的对象时自动执行,无需调用。
静态变量和实例变量区别
- 静态变量: 静态变量由于不属于任何实例对象,属于类的,所以在内存中只会 有一份,在类的加载过程 中,JVM 只为静态变量分配一次内存空间。
- 实例变量: 每次创建对象,都会为每个对象分配成员变量内存空间,实例变量 是属于实例对象的,在内 存中,创建几次对象,就有几份成员变量。
静态变量与普通变量区别
static 变量也称作静态变量,静态变量和非静态变量的区别是:静态变量被所有 的对象所共享,在内存 中只有一个副本,它当且仅当在类初次加载时会被初始 化。而非静态变量是对象所拥有的,在创建对象 的时候被初始化,存在多个副 本,各个对象拥有的副本互不影响。
还有一点就是 static 成员变量的初始化顺序按照定义的顺序进行初始化。
静态方法和实例方法有何不同?
静态方法和实例方法的区别主要体现在两个方面:
- 在外部调用静态方法时,可以使用"类名.方法名"的方式,也可以使 用"对象名.方法名"的方式。而 实例方法只有后面这种方式。也就是说,调 用静态方法可以无需创建对象。
- 静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量 和静态方法),而不允许访 问实例成员变量和实例方法;实例方法则无此 限制
在一个静态方法内调用一个非静态成员为什么是非法的?
由于静态方法可以不通过对象进行调用,因此在静态方法里,不能调用其他非静 态变量,也不可以访问 非静态变量成员。
什么是方法的返回值?返回值的作用是什么?
方法的返回值是指我们获取到的某个方法体中的代码执行后产生的结果!(前提 是该方法可能产生结 果)。返回值的作用:接收出结果,使得它可以用于其他的 操作!
内部类
什么是内部类?
在 Java 中,可以将一个类的定义放在另外一个类的定义内部,这就是内部类。内 部类本身就是类的一个 属性,与其他属性定义方式一致。
内部类的分类有哪些
内部类可以分为四种:成员内部类、局部内部类、匿名内部类和静态内部类。
- 静态内部类 定义在类内部的静态类,就是静态内部类。
1 public class Outer {
2 3
private static int radius = 1;
4 5
static class StaticInner {
6 public void visit() {
7 System.out.println("visit outer static variable:" + radius);
8 }
9 }
10 }
静态内部类可以访问外部类所有的静态变量,而不可访问外部类的非静态变量; 静态内部类的创建方 式,new 外部类.静态内部类(),如下:
1 Outer.StaticInner inner = new Outer.StaticInner();
2 inner.visit
- 成员内部类 定义在类内部,成员位置上的非静态类,就是成员内部类。
1 public class Outer {
2 3
private static int radius = 1;
4 private int count =2;
5 6
class Inner {
7 public void visit() {
8 System.out.println("visit outer static variable:" + radius);
9 System.out.println("visit outer variable:" + count);
10 }
11 }
12 }
成员内部类可以访问外部类所有的变量和方法,包括静态和非静态,私有和公 有。成员内部类依赖于外 部类的实例,它的创建方式外部类实例.new 内部类(),如 下:
1 Outer outer = new Outer();
2 Outer.Inner inner = outer.new Inner();
3 inner.visit();
- 局部内部类 定义在方法中的内部类,就是局部内部类
1 public class Outer {
2 3
private int out_a = 1;
4 private static int STATIC_b = 2;
5 6
public void testFunctionClass(){
7 int inner_c =3;
8 class Inner {
9 private void fun(){
10 System.out.println(out_a);
11 System.out.println(STATIC_b);
12 System.out.println(inner_c);
13 }
14 }
15 Inner inner = new Inner();
16 inner.fun();
17 }
18 public static void testStaticFunctionClass(){
19 int d =3;
20 class Inner {
21 private void fun(){
22 // System.out.println(out_a); 编译错误,定义在静态方法中的局部类不可以访问外
部类的实例变量
23 System.out.println(STATIC_b);
24 System.out.println(d);
25 }
26 }
27 Inner inner = new Inner();
28 inner.fun();
29 }
30 }
定义在实例方法中的局部类可以访问外部类的所有变量和方法,定义在静态方法 中的局部类只能访问外 部类的静态变量和方法。局部内部类的创建方式,在对应 方法内,new 内部类(),如下:
1 public static void testStaticFunctionClass(){
2 class Inner {
3 }
4 Inner inner = new Inner();
5 }
- 匿名内部类 匿名内部类就是没有名字的内部类,日常开发中使用的比较多。
1 public class Outer {
2 3
private void test(final int i) {
4 new Service() {
5 public void method() {
6 for (int j = 0; j < i; j++) {
7 System.out.println("匿名内部类" );
8 }
9 }
10 }.method();
11 }
12 }
13 //匿名内部类必须继承或实现一个已有的接口
14 interface Service{
15 void method();
16 }
除了没有名字,匿名内部类还有以下特点: 匿名内部类必须继承一个抽象类或者实现一个接口。 匿名内部类不能定义任何静态成员和静态方法。 当所在的方法的形参需要被匿名内部类使用时,必须声明为 final。 匿名内部类不能是抽象的,它必须要实现继承的类或者实现的接口的所有抽象方 法。 匿名内部类创建方式:
1 new 类/接口{
2 //匿名内部类实现部分
3 }
内部类的优点
我们为什么要使用内部类呢?因为它有以下优点: 一个内部类对象可以访问创建它的外部类对象的内容,包括私有数据! 内部类不为同一包的其他类所见,具有很好的封装性; 内部类有效实现了“多重继承”,优化 java 单继承的缺陷。 匿名内部类可以很方便的定义回调。
内部类有哪些应用场景
- 一些多算法场合
- 解决一些非面向对象的语句块。
- 适当使用内部类,使得代码更加灵活和富有扩展性。
- 当某个类除了它的外部类,不再被其他的类使用时。
局部内部类和匿名内部类访问局部变量的时候,为什么变量必须 要加上 final?
局部内部类和匿名内部类访问局部变量的时候,为什么变量必须要加上 final 呢? 它内部原理是什么呢? 先看这段代码:
1 public class Outer {
2 3
void outMethod(){
4 final int a =10;
5 class Inner {
6 void innerMethod(){
7 System.out.println(a);
8 }
9 1
0 }
11 }
12 }
以上例子,为什么要加 final 呢?是因为生命周期不一致, 局部变量直接存储在 栈中,当方法执行结束 后,非 final 的局部变量就被销毁。而局部内部类对局部变 量的引用依然存在,如果局部内部类要调用局 部变量时,就会出错。加了 final, 可以确保局部内部类使用的变量与外层的局部变量区分开,解决了这 个问题。
内部类相关,看程序说出运行结果
1 public class Outer {
2 private int age = 12;
3 4
class Inner {
5 private int age = 13;
6 public void print() {
7 int age = 14;
8 System.out.println("局部变量:" + age);
9 System.out.println("内部类变量:" + this.age);
10 System.out.println("外部类变量:" + Outer.this.age);
11 }
12 }
13
14 public static void main(String[] args) {
15 Outer.Inner in = new Outer().new Inner();
16 in.print();
17 }
18
19 }
1 局部变量:14
2 内部类变量:13
3 外部类变量:12
重写与重载
构造器(constructor)是否可被重写(override)
构造器不能被继承,因此不能被重写,但可以被重载。
重载(Overload)和重写(Override)的区别。重载的方法能 否根据返回类型进行区分?
方法的重载和重写都是实现多态的方式,区别在于前者实现的是编译时的多态 性,而后者实现的是运行 时的多态性。
重载:发生在同一个类中,方法名相同参数列表不同(参数类型不同、个数不 同、顺序不同),与方法 返回值和访问修饰符无关,即重载的方法不能根据返回 类型进行区分
重写:发生在父子类中,方法名、参数列表必须相同,返回值小于等于父类,抛 出的异常小于等于父 类,访问修饰符大于等于父类(里氏代换原则);如果父类 方法访问修饰符为 private 则子类中就不是重 写
对象相等判断
== 和 equals 的区别是什么
== : 它的作用是判断两个对象的地址是不是相等。即,判断两个对象是不是同 一个对象。(基本数据类型 == 比较的是值,引用数据类型 == 比较的是内存地址) equals() : 它的作用也是判断两个对象是否相等。但它一般有两种使用情况: 情况 1:类没有覆盖 equals() 方法。则通过 equals() 比较该类的两个对象时, 等价于通过“==”比较这两个对象。 情况 2:类覆盖了 equals() 方法。一般,我们都覆盖 equals() 方法来两个对象 的内容相等;若它们的内 容相等,则返回 true (即,认为这两个对象相等)。 举个例子:
1 public class test1 {
2 public static void main(String[] args) {
3 String a = new String("ab"); // a 为一个引用
4 String b = new String("ab"); // b为另一个引用,对象的内容一样
5 String aa = "ab"; // 放在常量池中
6 String bb = "ab"; // 从常量池中查找
7 if (aa == bb) // true
8 System.out.println("aa==bb");
9 if (a == b) // false,非同一对象
10 System.out.println("a==b");
11 if (a.equals(b)) // true
12 System.out.println("aEQb");
13 if (42 == 42.0) { // true
14 System.out.println("true");
15 }
16 }
17 }
说明:
- String 中的 equals 方法是被重写过的,因为 object 的 equals 方法是比较的对象的 内存地址,而 String 的 equals 方法比较的是对象的值。
- 当创建 String 类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要 创建的值相同的对 象,如果有就把它赋给当前引用。如果没有就在常量池中重新创建 一个 String 对象。
hashCode 与 equals (重要)
HashSet 如何检查重复
两个对象的 hashCode() 相同,则 equals() 也一定为 true,对吗?
hashCode 和 equals 方法的关系
面试官可能会问你:“你重写过 hashcode 和 equals 么,为什么重写 equals 时 必须重写 hashCode 方 法?”
hashCode()介绍
hashCode() 的作用是获取哈希码,也称为散列码;它实际上是返回一个 int 整 数。这个哈希码的作用是 确定该对象在哈希表中的索引位置。hashCode() 定义 在 JDK 的 Object.java 中,这就意味着 Java 中的任何 类都包含有 hashCode()函数。
散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出 对应的“值”。这其中就利用 到了散列码!(可以快速找到所需要的对象)
为什么要有 hashCode
我们以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode:
当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对 象加入的位置,同时也 会与其他已经加入的对象的 hashcode 值作比较,如果 没有相符的 hashcode,HashSet 会假设对象没 有重复出现。但是如果发现有相 同 hashcode 值的对象,这时会调用 equals()方法来检查 hashcode 相 等的对 象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不 同的话,就会重 新散列到其他位置。(摘自我的 Java 启蒙书《Head first java》 第二版)。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速 度。
hashCode()与 equals()的相关规定
如果两个对象相等,则 hashcode 一定也是相同的 两个对象相等,对两个对象分别调用 equals 方法都返 回 true 两个对象有相同的 hashcode 值,它们也不一定是相等的
因此,equals 方法被覆盖过,则 hashCode 方法也必须被覆盖
hashCode() 的默认行为是对堆上的对象产生独特值。如果没有重写
hashCode(),则该 class 的两个对象无论如何都不会相等(即使这两个对象指 向相同的数据)
对象的相等与指向他们的引用相等,两者有什么不同?
对象的相等 比的是内存中存放的内容是否相等而 引用相等 比较的是他们指向的 内存地址是否相等
值传递
当一个对象被当作参数传递到一个方法后,此方法可改变这个对 象的属性,并可返回变化后的结果,那么这里到底是值传递还是 引用传递
是值传递。Java 语言的方法调用只支持参数的值传递。当一个对象实例作为一 个参数被传递到方法中 时,参数的值就是对该对象的引用。对象的属性可以在被 调用过程中被改变,但对对象引用的改变是不 会影响到调用者的
为什么 Java 中只有值传递
首先回顾一下在程序设计语言中有关将参数传递给方法(或函数)的一些专业术 语。按值调用(call by value)表示方法接收的是调用者提供的值,而按引用调用 (call by reference)表示方法接收的是调用 者提供的变量地址。一个方法可以 修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量 值。 它用 来描述各种程序设计语言不只是 Java 中方法参数传递方式。
Java 程序设计语言总是采用按值调用。也就是说,方法得到的是所有参数值的 一个拷贝,也就是说, 方法不能修改传递给它的任何参数变量的内容。
下面通过 3 个例子来给大家说明 example 1
1 public static void main(String[] args) {
2 int num1 = 10;
3 int num2 = 20;
4 5
swap(num1, num2);
6 7
System.out.println("num1 = " + num1);
8 System.out.println("num2 = " + num2);
9 }
10
11 public static void swap(int a, int b) {
12 int temp = a;
13 a = b;
14 b = temp;
15
16 System.out.println("a = " + a);
17 System.out.println("b = " + b);
18 }
1 a = 20
2 b = 10
3 num1 = 10
4 num2 = 20
在 swap 方法中,a、b 的值进行交换,并不会影响到 num1、num2。因为,a、 b 中的值,只是从 num1、num2 的复制过来的。也就是说,a、b 相当于 num1、num2 的副本,副本的内容无论怎么修 改,都不会影响到原件本身。
通过上面例子,我们已经知道了一个方法不能修改一个基本数据类型的参数,而 对象引用作为参数就不 一样,请看 example2.
example 2
1 public static void main(String[] args) {
2 int[] arr = { 1, 2, 3, 4, 5 };
3 System.out.println(arr[0]);
4 change(arr);
5 System.out.println(arr[0]);
6 }
7 8
public static void change(int[] array) {
9 // 将数组的第一个元素变为0
10 array[0] = 0;
11 }
array 被初始化 arr 的拷贝也就是一个对象的引用,也就是说 array 和 arr 指向 的时同一个数组对象。 因此,外部对引用对象的改变会反映到所对应的对象 上。
通过 example2 我们已经看到,实现一个改变对象参数状态的方法并不是一件 难事。理由很简单,方法 得到的是对象引用的拷贝,对象引用及其他的拷贝同时 引用同一个对象。
很多程序设计语言(特别是,C++和 Pascal)提供了两种参数传递的方式:值调 用和引用调用。有些程序 员(甚至本书的作者)认为 Java 程序设计语言对对象 采用的是引用调用,实际上,这种理解是不对的。 由于这种误解具有一定的普遍 性,所以下面给出一个反例来详细地阐述一下这个问题。
example 3
1 public class Test {
2 3
public static void main(String[] args) {
4 // TODO Auto-generated method stub
5 Student s1 = new Student("小张");
6 Student s2 = new Student("小李");
7 Test.swap(s1, s2);
8 System.out.println("s1:" + s1.getName());
9 System.out.println("s2:" + s2.getName());
10 }
11
12 public static void swap(Student x, Student y) {
13 Student temp = x;
14 x = y;
15 y = temp;
16 System.out.println("x:" + x.getName());
17 System.out.println("y:" + y.getName());
18 }
19 }
1 x:小李
2 y:小张
3 s1:小张
4 s2:小李
通过上面两张图可以很清晰的看出: 方法并没有改变存储在变量 s1 和 s2 中的 对象引用。swap 方法的 参数 x 和 y 被初始化为两个对象引用的拷贝,这个方法交 换的是这两个拷贝 总结 Java 程序设计语言对对象采用的不是引用调用,实际上,对象引用是按值传递的。 下面再总结一下 Java 中方法参数的使用情况: 一个方法不能修改一个基本数据类型的参数(即数值型或布尔型》 一个方法可以改变一个对象参数的状态。 一个方法不能让对象参数引用一个新的对象。
值传递和引用传递有什么区别
值传递:指的是在方法调用时,传递的参数是按值的拷贝传递,传递的是值的拷 贝,也就是说传递后就 互不相关了。 引用传递:指的是在方法调用时,传递的参数是按引用进行传递,其实传递的引 用的地 址,也就是变量所对应的内存空间的地址。传递的是值的引用,也就是说 传递前和传递后都指向同一个 引用(也就是同一个内存空间)。
Java 包
JDK 中常用的包有哪些
- java.lang:这个是系统的基础类;
- java.io:这里面是所有输入输出有关的类,比如文件操作等;
- java.nio:为了完善 io 包中的功能,提高 io 包中性能而写的一个新包;
- java.net:这里面是与网络有关的类;
- java.util:这个是系统辅助类,特别是集合类;
- java.sql:这个是数据库操作的类。
import java 和 javax 有什么区别
刚开始的时候 JavaAPI 所必需的包是 java 开头的包,javax 当时只是扩展 API 包来说使用。然而随着时 间的推移,javax 逐渐的扩展成为 Java API 的组成部 分。但是,将扩展从 javax 包移动到 java 包将是太 麻烦了,最终会破坏一堆现 有的代码。因此,最终决定 javax 包将成为标准 API 的一部分。 所以,实际 上 java 和 javax 没有区别。这都是一个名字。
IO 流
java 中 IO 流分为几种?
按照流的流向分,可以分为输入流和输出流; 按照操作单元划分,可以划分为字节流和字符流; 按照流 的角色划分为节点流和处理流。 Java Io 流共涉及 40 多个类,这些类看上去很杂乱,但实际上很有规则, 而且彼 此之间存在非常紧密的联系, Java I0 流的 40 多个类都是从如下 4 个抽象类基类 中派生出来的。
- InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符 输入流。
- OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输 出流。 按操作方式分类结构图:
BIO,NIO,AIO 有什么区别?
简答
- BIO:Block IO 同步阻塞式 IO,就是我们平常使用的传统 IO,它的特点是模式简单使用方便,并 发处理能力低。
- NIO:Non IO 同步非阻塞 IO,是传统 IO 的升级,客户端和服务器端通过 Channel(通道)通讯,实现了多路复用。
- AIO:Asynchronous IO 是 NIO 的升级,也叫 NIO2,实现了异步非堵塞 IO ,异步 IO 的操作基于事件和回调机制。
详细回答
- BIO (Blocking I/O): 同步阻塞 I/O 模式,数据的读取写入必须阻塞在一个线程内等待其完成。在活动 连接数不是特别高(小于单机 1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于 自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天 然的漏斗,可以缓冲一些系统处理不了的连接或请求。但是,当面对十万甚至百万级连接的时候, 传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
- NIO (New I/O): NIO 是一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入了 NIO 框架,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 NIO 提供了与传统 BIO 模型中的 Socket 和 ServerSocket 相对应的 SocketChannel 和 ServerSocketChannel 两种不同的套接字通道实现,两 种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能 和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞 模式来开发
- AIO (Asynchronous I/O): AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非 阻塞的 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵 塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。AIO 是异步 IO 的缩写, 虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来 说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO 操作本身是同步的。查阅网上相关资料,我发现就目前来说 AIO 的应用还不是很广泛,Netty 之前也尝试使用过 AIO,不过又放弃了。
Files 的常用方法都有哪些?
- Files. exists():检测文件路径是否存在。
- Files. createFile():创建文件。
- Files. createDirectory():创建文件夹。
- Files. delete():删除一个文件或目录。
- Files. copy():复制文件。
- Files. move():移动文件。
- Files. size():查看文件个数。
- Files. read():读取文件。
- Files. write():写入文件。
反射
什么是反射机制?
JAVA 反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个 对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为 java 语言的反射机制。
静态编译和动态编译
- 静态编译:在编译时确定类型,绑定对象
- 动态编译:运行时确定类型,绑定对象
反射机制优缺点
优点: 运行期类型的判断,动态加载类,提高代码灵活度。
缺点: 性能瓶颈:反射相当于一系列解释操作,通知 JVM 要做的事情,性能 比直接的 java 代码要慢很多。
反射机制的应用场景有哪些?
反射是框架设计的灵魂。
在我们平时的项目开发过程中,基本上很少会直接使用到反射机制,但这不能说明反射机制没有用,实 际上有很多设计、开发都与反射机制有关,例如模块化的开发,通过反射去调用对应的字节码;动态代 理设计模式也采用了反射机制,还有我们日常使用的 Spring/Hibernate 等框架也大量使用到了反射机 制。
举例:
- ① 我们在使用 JDBC 连接数据库时使用 Class.forName()通过反射加载数据库的驱动程序;
- ②Spring 框架也用到很多反射机制, 经典的就是 xml 的配置模式。Spring 通过 XML 配置模式装载 Bean 的过程:
- 将程序内所有 XML 或 Properties 配置文件加载入内存中;
- 2)Java 类里面解析 xml 或 properties 里面的内容,得到对应实体类的字节码字符串以及相关的属性信息;
- 3)使用反射机制,根据这 个字符串获得某个类的 Class 实例;
- 4)动态配置实例的属性
Java 获取反射的三种方法
- 1.通过 new 对象实现反射机制
- 2.通过路径实现反射机制
- 3.通过类名实现反射机制
1 public class Student {
2 private int id;
3 String name;
4 protected boolean sex;
5 public float score;
6 }
1 public class Get {
2 //获取反射机制三种方式
3 public static void main(String[] args) throws ClassNotFoundException {
4 //方式一(通过建立对象)
5 Student stu = new Student();
6 Class classobj1 = stu.getClass();
7 System.out.println(classobj1.getName());
8 //方式二(所在通过路径-相对路径)
9 Class classobj2 = Class.forName("fanshe.Student");
10 System.out.println(classobj2.getName());
11 //方式三(通过类名)
12 Class classobj3 = Student.class;
13 System.out.println(classobj3.getName());
14 }
15 }
常用 API
String 相关
字符型常量和字符串常量的区别
- 形式上: 字符常量是单引号引起的一个字符 字符串常量是双引号引起的若干个字符
- 含义上: 字符常量相当于一个整形值(ASCII 值),可以参加表达式运算 字符串常量代表一个地址值(该字 符串在内存中存放位置)
- 占内存大小 字符常量只占一个字节 字符串常量占若干个字节(至少一个字符结束标志)
什么是字符串常量池?
字符串常量池位于堆内存中,专门用来存储字符串常量,可以提高内存的使用率,避免开辟多块空间存 储相同的字符串,在创建字符串时 JVM 会首先检查字符串常量池,如果该字符串已经存在池中,则返回 它的引用,如果不存在,则实例化一个字符串放到池中,并返回其引用。
String 是最基本的数据类型吗
不是。Java 中的基本数据类型只有 8 个 :byte、short、int、long、float、 double、char、 boolean;除了基本类型(primitive type),剩下的都是引用类型(referencetype),Java 5 以后引 入的枚举类型也算是一种比较特殊的引用类型。
这是很基础的东西,但是很多初学者却容易忽视,Java 的 8 种基本数据类型中不包括 String,基本数据 类型中用来描述文本数据的是 char,但是它只能表示单个字符,比如 ‘a’,‘好’ 之类的,如果要描述一段文 本,就需要用多个 char 类型的变量,也就是一个 char 类型数组,比如“你好” 就是长度为 2 的数组 char[] chars = {‘你’,‘好’};
但是使用数组过于麻烦,所以就有了 String,String 底层就是一个 char 类型的数组,只是使用的时候开 发者不需要直接操作底层数组,用更加简便的方式即可完成对字符串的使用。
String 有哪些特性
- 不变性:String 是只读字符串,是一个典型的 immutable 对象,对它进行任何操作,其实都是创 建一个新的对象,再把引用指向该对象。不变模式的主要作用在于当一个对象需要被多线程共享并 频繁访问时,可以保证数据的一致性。
- 常量池优化:String 对象创建之后,会在字符串常量池中进行缓存,如果下次创建同样的对象时, 会直接返回缓存的引用。
- final:使用 final 来定义 String 类,表示 String 类不能被继承,提高了系统的安全性。
String 为什么是不可变的吗?
简单来说就是 String 类利用了 final 修饰的 char 类型数组存储字符,源码如下图所以:
1 /** The value is used for character storage. */
2 private final char value[];
String 真的是不可变的吗?
我觉得如果别人问这个问题的话,回答不可变就可以了。 下面只是给大家看两个有代表性的例子:
- String 不可变但不代表引用不可以变
1 String str = "Hello";
2 str = str + " World";
3 System.out.println("str=" + str);
1 str=Hello World
解析: 实际上,原来 String 的内容是不变的,只是 str 由原来指向"Hello"的内存地址转为指向"Hello World"的内 存地址而已,也就是说多开辟了一块内存区域给"Hello World"字符串。
- 通过反射是可以修改所谓的“不可变”对象
1 // 创建字符串"Hello World", 并赋给引用s
2 String s = "Hello World";
3 4
System.out.println("s = " + s); // Hello World
5 6
// 获取String类中的value字段
7 Field valueFieldOfString = String.class.getDeclaredField("value");
8 9
// 改变value属性的访问权限
10 valueFieldOfString.setAccessible(true);
11
12 // 获取s对象上的value属性的值
13 char[] value = (char[]) valueFieldOfString.get(s);
14
15 // 改变value所引用的数组中的第5个字符
16 value[5] = '_';
17
18 System.out.println("s = " + s); // Hello_World
1 s = Hello World 2 s = Hello_World
解析: 用反射可以访问私有成员, 然后反射出 String 对象中的 value 属性, 进而改变通过获得的 value 引用改变 数组的结构。但是一般我们不会这么做,这里只是简单提一下有这个东西。
是否可以继承 String 类
String 类是 final 类,不可以被继承。
String str="i"与 String str=new String(“i”)一样吗?
不一样,因为内存的分配方式不一样。String str="i"的方式,java 虚拟机会将其分配到常量池中;而 String str=new String(“i”) 则会被分到堆内存 中。 String s = new String(“xyz”);创建了几个字符串对象两个对象,一个是静态区的"xyz",一个是用 new 创建在堆上的对象
1 String str1 = "hello"; //str1指向静态区
2 String str2 = new String("hello"); //str2指向堆上的对象
3 String str3 = "hello";
4 String str4 = new String("hello");
5 System.out.println(str1.equals(str2)); //true
6 System.out.println(str2.equals(str4)); //true
7 System.out.println(str1 == str3); //true
8 System.out.println(str1 == str2); //false
9 System.out.println(str2 == str4); //false
10 System.out.println(str2 == "hello"); //false
11 str2 = str1;
12 System.out.println(str2 == "hello"); //true
如何将字符串反转?
使用 StringBuilder 或者 stringBuffer 的 reverse() 方法。示例代码:
1 // StringBuffer reverse
2 StringBuffer stringBuffer = new StringBuffer();
3 stringBuffer. append("abcdefg");
4 System. out. println(stringBuffer. reverse()); // gfedcba
5 // StringBuilder reverse
6 StringBuilder stringBuilder = new StringBuilder();
7 stringBuilder. append("abcdefg");
8 System. out. println(stringBuilder. reverse()); // gfedcba
数组有没有 length()方法?String 有没有 length()方法
数组没有 length()方法 ,有 length 的属性。String 有 length()方法。 JavaScript 中,获得字符串的长度 是通过 length 属性得到的,这一点容易和 Java 混淆。
String 类的常用方法都有那些?
- indexOf():返回指定字符的索引。
- charAt():返回指定索引处的字符。
- replace():字符串替换。
- trim():去除字符串两端空白。
- split():分割字符串,返回一个分割后的字符串数组。
- getBytes():返回字符串的 byte 类型数组。
- length():返回字符串长度。
- toLowerCase():将字符串转成小写字母。
- toUpperCase():将字符串转成大写字符。
- substring():截取字符串。
- equals():字符串比较。
在使用 HashMap 的时候,用 String 做 key 有什么好处?
HashMap 内部实现是通过 key 的 hashcode 来确定 value 的存储位置,因为字符串是不可变的,所以 当创建字符串时,它的 hashcode 被缓存下来,不需要再次计算,所以相比于其他对象更快。
String 和 StringBuffer、StringBuilder 的区别是什么?String 为什么是不可变的
可变性
String 类中使用字符数组保存字符串,private final char value[],所以 string 对象是不可变的。
StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,char[] value,这 两种对象都是可变的。
线程安全性
String 中的对象是不可变的,也就可以理解为常量,线程安全。
AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 对方法加了同步锁或者对调用 的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全 的。
性能
每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。
StringBuffer 每次都会对 StringBuffer 对象本身进行操 作,而不是生成新的对象并改变对象引用。相同情况下使用 StirngBuilder 相比使用 StringBuffer 仅能获 得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
对于三者使用的总结
如果要操作少量的数据用 = String 单线程操作字符串缓冲区 下操作大量数据 = StringBuilder 多线程操 作字符串缓冲区 下操作大量数据 = StringBuffer
包装类相关
自动装箱与拆箱
- 装箱:将基本类型用它们对应的引用类型包装起来;
拆箱:将包装类型转换为基本数据类型; int 和 Integer 有什么区别
Java 是一个近乎纯洁的面向对象编程语言,但是为了编程的方便还是引入了基本数据类型,但是为了能 够将这些基本数据类型当成对象操作,Java 为每一个基本数据类型都引入了对应的包装类型(wrapper class),int 的包装类就是 Integer,从 Java 5 开始引入了自动装箱/拆箱机制,使得二者可以相互转 换。 Java 为每个原始类型提供了包装类型: 原始类型: boolean,char,byte,short,int,long,float,double 包装类型:Boolean, Character,Byte,Short,Integer,Long,Float,Double
Integer a= 127 与 Integer b = 127 相等吗
对于对象引用类型:==比较的是对象的内存地址。
对于基本数据类型:==比较的是值。如果整型字面量的值在-128 到 127 之间,那么自动装箱时不会 new 新的 Integer 对象,而是直接引用常量池中的 Integer 对象,超过范围 a1==b1 的结果是 false
1 public static void main(String[] args) {
2 Integer a = new Integer(3);
3 Integer b = 3; // 将3自动装箱成Integer类型
4 int c = 3;
5 System.out.println(a == b); // false 两个引用没有引用同一对象
6 System.out.println(a == c); // true a自动拆箱成int类型再和c比较
7 System.out.println(b == c); // true
89 Integer a1 = 128;
10 Integer b1 = 128;
11 System.out.println(a1 == b1); // false
12
13 Integer a2 = 127;
14 Integer b2 = 127;
15 System.out.println(a2 == b2); // true
16 }
集合容器概述
什么是集合
- 集合框架:用于存储数据的容器。
集合框架是为表示和操作集合而规定的一种统一的标准的体系结构。 任何集合框架都包含三大块内容: 对外的接口、接口的实现和对集合运算的算 法。
- 接口:表示集合的抽象数据类型。接口允许我们操作集合时不必关注具体实现, 从而达到“多态”。在面 向对象编程语言中,接口通常用来形成规范。
- 实现:集合接口的具体实现,是重用性很高的数据结构。
- 算法:在一个实现了某个集合框架中的接口的对象身上完成某种有用的计算的方 法,例如查找、排序 等。这些算法通常是多态的,因为相同的方法可以在同一个 接口被多个类实现时有不同的表现。事实 上,算法是可复用的函数。 它减少了程序设计的辛劳。 集合框架通过提供有用的数据结构和算法使你能集中注意力于你的程序的重要部 分上,而不是为了让程 序能正常运转而将注意力于底层设计上。 通过这些在无关 API 之间的简易的互用性,使你免除了为改编对象或转换代码以 便联合这些 API 而去写大 量的代码。 它提高了程序速度和质量。
集合的特点
集合的特点主要有如下两点:
- 对象封装数据,对象多了也需要存储。集合用于存储对象。
- 对象的个数确定可以使用数组,对象的个数不确定的可以用集合。因 为集合是可变长度的。
集合和数组的区别
- 数组是固定长度的;集合可变长度的。
- 数组可以存储基本数据类型,也可以存储引用数据类型;集合只能存 储引用数据类型。
- 数组存储的元素必须是同一个数据类型;集合存储的对象可以是不同 数据类型。
数据结构:就是容器中存储数据的方式。
对于集合容器,有很多种。因为每一个容器的自身特点不同,其实原理在于每个 容器的内部数据结构不 同。 集合容器在不断向上抽取过程中,出现了集合体系。在使用一个体系的原则:参 阅顶层内容。建立底层 对象
使用集合框架的好处
- 容量自增长;
- 提供了高性能的数据结构和算法,使编码更轻松,提高了程序速度和质量;
- 允许不同 API 之间的互操作,API 之间可以来回传递集合;
- 可以方便地扩展或改写集合,提高代码复用性和可操作性。
- 通过使用 JDK 自带的集合类,可以降低代码维护和学习新 API 成本。
常用的集合类有哪些?
Map 接口和 Collection 接口是所有集合框架的父接口:
- Collection 接口的子接口包括:Set 接口和 List 接口
- Map 接口的实现类主要有:HashMap、TreeMap、Hashtable、 ConcurrentHashMap 以及 Properties 等
- Set 接口的实现类主要有:HashSet、TreeSet、LinkedHashSet 等
- List 接口的实现类主要有:ArrayList、LinkedList、Stack 以及 Vector 等
List,Set,Map 三者的区别?List、Set、Map 是否继 承自 Collection 接口?List、Map、Set 三个接口存取 元素时,各有什么特点?
Java 容器分为 Collection 和 Map 两大类,Collection 集合的子接口有 Set、 List、Queue 三种子接口。 我们比较常用的是 Set、List,Map 接口不是 collection 的子接口。
Collection 集合主要有 List 和 Set 两大接口
- List:一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重 复,可以插入多个 null 元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。
- Set:一个无序(存入和取出顺序有可能不一致)容器,不可以存储重复元素, 只允许存入一个 null 元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、 LinkedHashSet 以及 TreeSet。
Map 是一个键值对集合,存储键、值和之间的映射。 Key 无序,唯一;value 不 要求有序,允许重复。 Map 没有继承于 Collection 接口,从 Map 集合中检索元 素时,只要给出键对象,就会返回对应的值对 象。
Map 的常用实现类:HashMap、TreeMap、HashTable、LinkedHashMap、 ConcurrentHashMap
集合框架底层数据结构
Collection
- List
- Arraylist: Object 数组
- Vector: Object 数组
- LinkedList: 双向循环链表
- Set
- HashSet(无序,唯一):基于 HashMap 实现的,底层采用 HashMap 来保存元素
- LinkedHashSet: LinkedHashSet 继承与 HashSet,并且其内部是通过 LinkedHashMap 来实现 的。有点类似于我们之前说的 LinkedHashMap 其内部是基 于 Hashmap 实现一样,不过还是有一 点点区别的。
- TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树。) Map
- HashMap: JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主 体,链表则是主要 为了解决哈希冲突而存在的(“拉链法”解决冲突).JDK1.8 以后 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转 化为红黑树, 以减少搜索时间
- LinkedHashMap:LinkedHashMap 继承自 HashMap,所以它的底层仍然是 基于拉链式散列结 构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面 结构的基础上,增加了一条双向 链表,使得上面的结构可以保持键值对的插入顺序。 同时通过对链表进行相应的操作,实现了访问 顺序相关逻辑。
- HashTable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为 了解决哈希冲突而存 在的
- TreeMap: 红黑树(自平衡的排序二叉树)
哪些集合类是线程安全的?
- vector:就比 arraylist 多了个同步化机制(线程安全),因为效率较低,现在已 经不太建议使用。 在 web 应用中,特别是前台页面,往往效率(页面响应速度)是优 先考虑的。
- statck:堆栈类,先进后出。
- hashtable:就比 hashmap 多了个线程安全。
- enumeration:枚举,相当于迭代器。
Java 集合的快速失败机制 “fail-fast”?
是 java 集合的一种错误检测机制,当多个线程对集合进行结构上的改变的操作 时,有可能会产生 failfast 机制。
例如:假设存在两个线程(线程 1、线程 2),线程 1 通过 Iterator 在遍历集合 A 中 的元素,在某个时候线 程 2 修改了集合 A 的结构(是结构上面的修改,而不是简 单的修改集合元素的内容),那么这个时候程序 就会抛出 ConcurrentModificationException 异常,从而产生 fail-fast 机制。
原因:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在 被遍历期间如果内容发生变化,就会改变 modCount 的值。每当迭代器使用 hashNext()/next()遍历下一 个元素之前,都会检测 modCount 变量是否为 expectedmodCount 值,是的话就返回遍历;否则抛出 异常,终止遍历。
解决办法:
- 在遍历过程中,所有涉及到改变 modCount 值得地方全部加上 synchronized。
- 使用 CopyOnWriteArrayList 来替换 ArrayList
怎么确保一个集合不能被修改?
可以使用 Collections. unmodifiableCollection(Collection c) 方法来创建一个 只读集合,这样改变集合 的任何操作都会抛出 Java. lang. UnsupportedOperationException 异常。 示例代码如下:
List<String> list = new ArrayList<>();
list. add("x");
Collection<String> clist = Collections. unmodifiableCollection(list);
clist. add("y"); // 运行时此行报错
System. out. println(list. size());
Collection 接口
List 接口
迭代器 Iterator 是什么?
Iterator 接口提供遍历任何 Collection 的接口。我们可以从一个 Collection 中使用迭代器方法来获取迭 代器实例。迭代器取代了 Java 集合框架中的 Enumeration,迭代器允许调用者在迭代过程中移除元 素。
Iterator 怎么使用?有什么特点?
Iterator 使用代码如下:
1 List<String> list = new ArrayList
2 Iterator<String> it = list. iterator
3 while(it. hasNext()){
4 String obj = it. next();
5 System. out. println(obj);
6 }
Iterator 的特点是只能单向遍历,但是更加安全,因为它可以确保,在当前遍历 的集合元素被更改的时候,就会抛出 ConcurrentModificationException 异常。
如何边遍历边移除 Collection 中的元素?
边遍历边修改 Collection 的唯一正确方式是使用 Iterator.remove() 方法,如下:
1 Iterator<Integer> it = list.iterator();
2 while(it.hasNext()){
3 *// do something*
4 it.remove();5 }
一种 常见的错误代码如下:
1 for(Integer i : list){
2 list.remove(i)
3 }
运行以上错误代码会报 ConcurrentModificationException 异常。这是因为当使用 foreach(for(Integer i : list)) 语句时,会自动生成一个 iterator 来遍历该 list,但同时该 list 正在被 Iterator.remove() 修改。 Java 一般不允许一个线程在遍历 Collection 时另一个线程修改它
Iterator 和 ListIterator 有什么区别?
Iterator 可以遍历 Set 和 List 集合,而 ListIterator 只能遍历 List。
Iterator 只能单向遍历,而 ListIterator 可以双向遍历(向前/后遍历)。
ListIterator 实现 Iterator 接口,然后添加了一些额外的功能,比如添加一个元 素、替换一个元 素、获取前面或后面元素的索引位置。
遍历一个 List 有哪些不同的方式?每种方法的实现原理是什 么?Java 中 List 遍历的最佳实践是什么?
遍历方式有以下几种:
- for 循环遍历,基于计数器。在集合外部维护一个计数器,然后依次读 取每一个位置的元素,当读 取到后一个元素后停止。
- 迭代器遍历,Iterator。Iterator 是面向对象的一个设计模式,目的是屏 蔽不同数据集合的特点, 统一遍历集合的接口。Java 在 Collections 中支 持了 Iterator 模式。
- foreach 循环遍历。foreach 内部也是采用了 Iterator 的方式实现,使 用时不需要显式声明 Iterator 或计数器。优点是代码简洁,不易出错;缺 点是只能做简单的遍历,不能在遍历过程中操 作数据集合,例如删除、替 换。
最佳实践:Java Collections 框架中提供了一个 RandomAccess 接口,用来标 记 List 实现是否支持 Random Access。
- 如果一个数据集合实现了该接口,就意味着它支持 Random Access,按位置读 取元素的平均时间 复杂度为 O(1),如 ArrayList。
- 如果没有实现该接口,表示不支持 Random Access,如 LinkedList。 推荐的做法就是,支持 Random Access 的列表可用 for 循环遍历,否则建议 用 Iterator 或 foreach 遍历。
说一下 ArrayList 的优缺点
ArrayList 的优点如下:
- ArrayList 底层以数组实现,是一种随机访问模式。ArrayList 实现了 RandomAccess 接口,因此查 找的时候非常快。
- ArrayList 在顺序添加一个元素的时候非常方便。
ArrayList 的缺点如下:
- 删除元素的时候,需要做一次元素复制操作。如果要复制的元素很多,那么就会比较耗费性能。
- 插入元素的时候,也需要做一次元素复制操作,缺点同上。
ArrayList 比较适合顺序添加、随机访问的场景。
如何实现数组和 List 之间的转换?
数组转 List:使用 Arrays. asList(array) 进行转换。
List 转数组:使用 List 自带的 toArray() 方法。代码示例:
1 // list to array
2 List<String> list = new ArrayList<String>();
3 list.add("123");
4 list.add("456");
5 list.toArray();
6 7
// array to list
8 String[] array = new String[]{"123","456"};
9 Arrays.asList(array);
ArrayList 和 LinkedList 的区别是什么?
- 数据结构实现:ArrayList 是动态数组的数据结构实现,而 LinkedList 是双向链表的数据结构实 现。
- 随机访问效率:ArrayList 比 LinkedList 在随机访问的时候效率要高,因为 LinkedList 是线性的数 据存储方式,所以需要移动指针从前往后依次查找。
- 增加和删除效率:在非首尾的增加和删除操作,LinkedList 要比 ArrayList 效率要高,因为 ArrayList 增删操作要影响数组内的其他数据的下标。
- 内存空间占用:LinkedList 比 ArrayList 更占内存,因为 LinkedList 的节点除了存储数据,还存储 了两个引用,一个指向前一个元素,一个指向后一个元素。
- 线程安全:ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
综合来说,在需要频繁读取集合中的元素时,更推荐使用 ArrayList,而在插入和删除操作较多时,更推 荐使用 LinkedList。
补充:数据结构基础之双向链表
双向链表也叫双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前 驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。
ArrayList 和 Vector 的区别是什么?
这两个类都实现了 List 接口(List 接口继承了 Collection 接口),他们都是有序集合
线程安全:Vector 使用了 Synchronized 来实现线程同步,是线程安全的,而 ArrayList 是非线程安全的。
性能:ArrayList 在性能方面要优于 Vector。
扩容:ArrayList 和 Vector 都会根据实际的需要动态的调整容量,只不过在
Vector 扩容每次会增加 1 倍,而 ArrayList 只会增加 50%。
Vector 类的所有方法都是同步的。可以由两个线程安全地访问一个 Vector 对 象、但是一个线程访问 Vector 的话代码要在同步操作上耗费大量的时间。
Arraylist 不是同步的,所以在不需要保证线程安全时时建议使用 Arraylist
插入数据时,ArrayList、LinkedList、Vector 谁速度较快?阐述 ArrayList、Vector、LinkedList 的存储性能和特性?
ArrayList、LinkedList、Vector 底层的实现都是使用数组方式存储数据。数组 元素数大于实际存储的数据以便增加和插入元素,它们都允许直接按序号索引元素,但是插入元素要涉 及数组元素移动等内存操作,所以索引数据快而插入数据慢。
Vector 中的方法由于加了 synchronized 修饰,因此 Vector 是线程安全容器,但性能上较 ArrayList 差。
LinkedList 使用双向链表实现存储,按序号索引数据需要进行前向或后向遍历,但插入数据时只需要记 录当前项的前后项即可,所以 LinkedList 插入速度较快。
多线程场景下如何使用 ArrayList?
ArrayList 不是线程安全的,如果遇到多线程场景,可以通过 Collections 的 synchronizedList 方法将其转换成线程安全的容器后再使用。例如像下面这样:
1 List<String> synchronizedList = Collections.synchronizedList(list);
2 synchronizedList.add("aaa");
3 synchronizedList.add("bbb");
4 5
for (int i = 0; i < synchronizedList.size(); i++) {
6 System.out.println(synchronizedList.get(i));
7 }
为什么 ArrayList 的 elementData 加上 transient 修饰?
ArrayList 中的数组定 义如下:
1 private transient Object[] elementData;
再看一下 ArrayList 的定义:
1 public class ArrayList<E> extends AbstractList<E>
2 implements List<E>, RandomAccess, Cloneable, java.io.Serializable
可以看到 ArrayList 实现了 Serializable 接口,这意味着 ArrayList 支持序列 化。transient 的作用是说不希望 elementData 数组被序列化,重写了 writeObject 实现:
1 private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOE
xception{
2 *// Write out element count, and any hidden stuff*
3 int expectedModCount = modCount;
4 s.defaultWriteObject();
5 *// Write out array length*
6 s.writeInt(elementData.length);
7 *// Write out all elements in the proper order.*
8 for (int i=0; i<size; i++)
9 s.writeObject(elementData[i]);
10 if (modCount != expectedModCount) {
11 throw new ConcurrentModificationException();
12 }
每次序列化时,先调用 defaultWriteObject() 方法序列化 ArrayList 中的非 transient 元素,然后遍历 elementData,只序列化已存入的元素,这样既加快了序列化的速度,又减小了序列化之后的文件大 小
List 和 Set 的区别
List , Set 都是继承自 Collection 接口
List 特点:一个有序(元素存入集合的顺序和取出的顺序一致)容器,元素可以重复,可以插入多个 null 元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。
Set 特点:一个无序(存入和取出顺序有可能不一致)容器,不可以存储重复元素,只允许存入一个 null 元素,必须保证元素唯一性。Set 接口常用实现类是 HashSet、LinkedHashSet 以及 TreeSet。
另外 List 支持 for 循环,也就是通过下标来遍历,也可以用迭代器,但是 set 只能用迭代,因为他无序, 无法用下标来取得想要的值。
Set 和 List 对比
- Set:检索元素效率低下,删除和插入效率高,插入和删除不会引起元素位置改变。
- List:和数组类似,List 可以动态增长,查找元素效率高,插入删除元素效率低,因为会引起其他元素位 置改变
Set 接口
说一下 HashSet 的实现原理?
HashSet 是基于 HashMap 实现的,HashSet 的值存放于 HashMap 的 key 上,HashMap 的 value 统一为 PRESENT,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成,HashSet 不允许重复的值。
HashSet 如何检查重复?HashSet 是如何保证数据不可重复的?
向 HashSet 中 add ()元素时,判断元素是否存在的依据,不仅要比较 hash 值,同时还要结合 equles 方法 比较。
HashSet 中的 add ()方法会使用 HashMap 的 put()方法。
HashMap 的 key 是唯一的,由源码可以看出 HashSet 添加进去的值就是作为 HashMap 的 key,并且 在 HashMap 中如果 K/V 相同时,会用新的 V 覆盖掉旧的 V,然后返回旧的 V。所以不会重复( HashMap 比较 key 是否相等是先比较 hashcode 再比较 equals )。
以下是 HashSet 部分源码:
1 private static final Object PRESENT = new Object();
2 private transient HashMap<E,Object> map;
3 4
public HashSet() {
<>
5 map = new HashMap ();
6 }
7 8
public boolean add(E e) {
9 // 调用HashMap的put方法,PRESENT是一个至始至终都相同的虚值
10 return map.put(e, PRESENT)==null;
11 }
hashCode()与 equals()的相关规定:
- 如果两个对象相等,则 hashcode 一定也是相同的
- 两个对象相等,对两个 equals 方法返回 true
- 两个对象有相同的 hashcode 值,它们也不一定是相等的
- 综上,equals 方法被覆盖过,则 hashCode 方法也必须被覆盖
- hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode(),则该 class 的两个 对象无论如何都不会相等(即使这两个对象指向相同的数据)
==与 equals 的区别
- ==是判断两个变量或实例是不是指向同一个内存空间 equals 是判断两个变量或实例所指向的内存 空间的值是不是相同
- ==是指对内存地址进行比较 equals()是对字符串的内容进行比较 3.== 指引用是否相同 equals()指的 是值是否相同
HashSet 与 HashMap 的区别
HashMap | HashSet |
---|---|
实现了 Map 接口 | 实现了 Set 接口 |
存储键值对 | 仅存储对象 |
调用 put()向 map 中添加元素 | 调用 add() 方法向 Set 中添加元素 |
HashMap 使用键(Key)计算 Hashcode | HashSet 使用成员对象来计 算 hashcode 值,对于两个对象 来说 hashcode 可能相 同,所以 equals()方法用来判断对象的相等性,如 果两个对象不同的话,那 么返回 false |
HashMap 相对于 HashSet 较快,因为它是使用唯一的键获取对象 | HashSet 较 HashMap 来说比较慢 |
Queue
BlockingQueue 是什么?
Java.util.concurrent.BlockingQueue 是一个队列,在进行检索或移除一个元素的时候,它会等待队列变 为非空;当在添加一个元素时,它会等待队列中的可用空间。
BlockingQueue 接口是 Java 集合框架的一 部分,主要用于实现生产者-消费者模式。我们不需要担心等待生产者有可用的空间,或消费者有可用的 对象,因为它都在 BlockingQueue 的实现类中被处理了。Java 提供了集中 BlockingQueue 的实现,比如 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue 等。
在 Queue 中 poll()和 remove()有什么区别?
- 相同点:都是返回第一个元素,并在队列中删除返回的对象。
- 不同点:如果没有元素 poll()会返回 null,而 remove()会直接抛出 NoSuchElementException 异 常。
代码示例:
1 Queue<String> queue = new LinkedList<String>();
2 queue. offer("string"); // add
3 System. out. println(queue. poll());
4 System. out. println(queue. remove());
5 System. out. println(queue. size());
Map 接口
说一下 HashMap 的实现原理?
HashMap 概述: HashMap 是基于哈希表的 Map 接口的非同步实现。此实现提供所有可选的映射操作, 并允许使用 null 值和 null 键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
HashMap 的数据结构: 在 Java 编程语言中, 基本的结构就是两种,一个是数组,另外一个是模拟指针 (引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap 也不例外。HashMap 实际上 是一个“链表散列”的数据结构,即数组和链表的结合体。
HashMap 基于 Hash 算法实现的
- 当我们往 HashMap 中 put 元素时,利用 key 的 hashCode 重新 hash 计算出当前对象的元素在数组中 的下标
- 存储时,如果出现 hash 值相同的 key,此时有两种情况。(1)如果 key 相 同,则覆盖原始值;(2)如果 key 不同(出现冲突),则将当前的 key-value 放入链表中
- 获取时,直接找到 hash 值对应的下标,在进一步判断 key 是否相同,从而找到对应值。
- 理解了以上过程就不难明白 HashMap 是如何解决 hash 冲突的问题,核心就是使用了数组的存储方 式,然后将冲突的 key 的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。
需要注意 Jdk 1.8 中对 HashMap 的实现做了优化,当链表中的节点数据超过八个 之后,该链表会转为红黑树来提高查询效率,从原来的 O(n)到 O(logn)
HashMap 在 JDK1.7 和 JDK1.8 中有哪些不同? HashMap 的底层实现
在 Java 中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除 困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各 自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。
JDK1.8 之前
JDK1.8 之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一 格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
JDK1.8 之后
相比于之前的版本,jdk1.8 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将 链表转化为红黑树,以减少搜索时间。
JDK1.7 VS JDK1.8 比较
JDK1.8 主要解决或优化了一下问题:
- resize 扩容优化
- 引入了红黑树,目的是避免单条链表过长而影响查询效率,红黑树算法请参考
- 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。
不同 | JDK 1.7 | JDK 1.8 |
---|---|---|
存储结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
初始化方式 | 单独函数: inflateTab le() | 直接集成到了扩容 函数 resize()中 |
hash 值计算方式 | 扰动处理 = 9 次扰动 = 4 次位运算+ 5 次异或运算 | 扰动处理 = 2 次扰动 = 1 次位运算 + 1 次异或运算 |
存放数据的规则 | 无冲突 时,存放 数组;冲 突时,存 放链表 | 无冲突时,存放 数组;冲 突 & 链表 长度 <8:存放单 链表;冲 突 & 链表 长度 > 8:树化并 存放红黑 树 |
插入数据方式 | 头插法 (先讲原 位置的数 据移到后 1 位,再插 入数据到 该位置) | 尾插法 (直接插 入到链表 尾部/红黑 树) |
扩容后存储位置的计算方式 | 全部按照 原来方法 进行计算 (即 hashCode ->> 扰动 函数 ->>(h&lengt h-1)) | 按照扩容 后的规律 计算(即 扩容后的 位置= 原位 置 or 原位 置 + 旧容 量) |
HashMap 的 put 方法的具体流程?
当我们 put 的时候,首先计算 key 的 hash 值,这里调用了 hash 方法,hash 方法实际是让 key.hashCode() 与 key.hashCode()>>>16 进行异或操作,高 16bit 补 0,一个数和 0 异或不变,所以 hash 函数大概的作用 就是:高 16bit 不变,低 16bit 和高 16bit 做了一个异或,目的是减少碰撞。按照函数注释,因为 bucket 数组大小是 2 的幂,计算下标 index = (table.length - 1) & hash,如果不做 hash 处理,相当于散列生效的只有几个 低 bit 位,为了减少散列的碰撞,设计者综合考虑了速度、作用、质量之后,使用高 16bit 和低 16bit 异或 来简单处理减少碰撞,而且
JDK8 中用了复杂度 O(logn)的树结构来提升碰撞下的性能。
putVal 方法执行流程图
1 public V put(K key, V value) {
2 return putVal(hash(key), key, value, false, true);
3 }
4 5
static final int hash(Object key) {
6 int h;
7 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
8 }
9 1
0 //实现Map.put和相关方法
11 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
12 boolean evict) {
13 Node<K,V>[] tab; Node<K,V> p; int n, i;
14 // 步骤①:tab为空则创建
15 // table未初始化或者长度为0,进行扩容
16 if ((tab = table) == null || (n = tab.length) == 0)
17 n = (tab = resize()).length;
18 // 步骤②:计算index,并对null做处理
19 // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这
个结点是放在数组中)
20 if ((p = tab[i = (n - 1) & hash]) == null)
21 tab[i] = newNode(hash, key, value, null);
22 // 桶中已经存在元素
23 else {
24 Node<K,V> e; K k;
25 // 步骤③:节点key存在,直接覆盖value
26 // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
27 if (p.hash == hash &&
28 ((k = p.key) == key || (key != null && key.equals(k))))
29 // 将第一个元素赋值给e,用e来记录
30 e = p;
31 // 步骤④:判断该链为红黑树
32 // hash值不相等,即key不相等;为红黑树结点
33 // 如果当前元素类型为TreeNode,表示为红黑树,putTreeVal返回待存放的node, e可
能为null
34 else if (p instanceof TreeNode)
35 // 放入树中
36 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
37 // 步骤⑤:该链为链表
38 // 为链表结点
39 else {
40 // 在链表最末插入结点
41 for (int binCount = 0; ; ++binCount) {
42 // 到达链表的尾部
43
44 //判断该链表尾部指针是不是空的
45 if ((e = p.next) == null) {
46 // 在尾部插入新结点
47 p.next = newNode(hash, key, value, null);
48 //判断链表的长度是否达到转化红黑树的临界值,临界值为8
49 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
50 //链表结构转树形结构
51 treeifyBin(tab, hash);
52 // 跳出循环
53 break;
54 }
55 // 判断链表中结点的key值与插入的元素的key值是否相等
56 if (e.hash == hash &&
57 ((k = e.key) == key || (key != null && key.equals(k))))
58 // 相等,跳出循环
59 break;
60 // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
61 p = e;
62 }
63 }
64 //判断当前的key已经存在的情况下,再来一个相同的hash值、key值时,返回新来的val
ue这个值
65 if (e != null) {
66 // 记录e的value
67 V oldValue = e.value;
68 // onlyIfAbsent为false或者旧值为null
69 if (!onlyIfAbsent || oldValue == null)
70 //用新值替换旧值
71 e.value = value;
72 // 访问后回调
73 afterNodeAccess(e);
74 // 返回旧值
75 return oldValue;
76 }
77 }
78 // 结构性修改
79 ++modCount;
80 // 步骤⑥:超过最大容量就扩容
81 // 实际大小大于阈值则扩容
82 if (++size > threshold)
83 resize();
84 // 插入后回调
85 afterNodeInsertion(evict);
86 return null;
87 }
- ①.判断键值对数组 table[i]是否为空或为 null,否则执行 resize()进行扩容;
- ②.根据键值 key 计算 hash 值得到插入的数组索引 i,如果 table[i]==null,直接新建节点添加,转向 ⑥,如 果 table[i]不为空,转向 ③;
- ③.判断 table[i]的首个元素是否和 key 一样,如果相同直接覆盖 value,否则转向
- ④,这里的相同指的是 hashCode 以及 equals;
- ④.判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值 对,否则转向 ⑤;
- ⑤.遍历 table[i],判断链表长度是否大于 8,大于 8 的话把链表转换为红黑树,在红黑树中执行插入操 作,否则进行链表的插入操作;遍历过程中若发现 key 已经存在直接覆盖 value 即可;
- ⑥.插入成功后,判断实际存在的键值对数量 size 是否超多了 大容量 threshold,如果超过,进行扩容。
HashMap 的扩容操作是怎么实现的?
- ①.在 jdk1.8 中,resize 方法是在 hashmap 中的键值对大于阀值时或者初始化时,就调用 resize 方法进行 扩容;
- ②.每次扩展的时候,都是扩展 2 倍;
- ③.扩展后 Node 对象的位置要么在原位置,要么移动到原偏移量两倍的位置。在 putVal()中,我们看到在 这个函数里面使用到了 2 次 resize()方法,resize()方法表示的在进行第一次初始化时会对其进行扩容,或 者当该数组的实际大小大于其临界值值(第一次为 12),这个时候在扩容的同时也会伴随的桶上面的元素进 行重新分发,这也是 JDK1.8 版本的一个优化的地方,在 1.7 中,扩容之后需要重新去计算其 Hash 值,根 据 Hash 值对其进行分发,但在 1.8 版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是 否为 0,重新进行 hash 分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大 小这个位置上
1 final Node<K,V>[] resize() {
2 Node<K,V>[] oldTab = table;//oldTab指向hash桶数组
3 int oldCap = (oldTab == null) ? 0 : oldTab.length;
4 int oldThr = threshold;
5 int newCap, newThr = 0;
6 if (oldCap > 0) {//如果oldCap不为空的话,就是hash桶数组不为空
7 if (oldCap >= MAXIMUM_CAPACITY) {//如果大于最大容量了,就赋值为整数最大的阀
值 8
threshold = Integer.MAX_VALUE;
9 return oldTab;//返回
10 }//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16
11 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
12 oldCap >= DEFAULT_INITIAL_CAPACITY)
13 newThr = oldThr << 1; // double threshold 双倍扩容阀值threshold
14 }
15 // 旧的容量为0,但threshold大于零,代表有参构造有cap传入,threshold已经被初
始化成最小2的n次幂
16 // 直接将该值赋给新的容量
17 else if (oldThr > 0) // initial capacity was placed in threshold
18 newCap = oldThr;
19 // 无参构造创建的map,给出默认容量和threshold 16, 16*0.75
20 else { // zero initial threshold signifies using defaults
21 newCap = DEFAULT_INITIAL_CAPACITY;
22 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
23 }
24 // 新的threshold = 新的cap * 0.75
25 if (newThr == 0) {
26 float ft = (float)newCap * loadFactor;
27 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
28 (int)ft : Integer.MAX_VALUE);
29 }
30 threshold = newThr;
31 // 计算出新的数组长度后赋给当前成员变量table
32 @SuppressWarnings({"rawtypes","unchecked"})
33 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶数组
34 table = newTab;//将新数组的值复制给旧的hash桶数组
35 // 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素
重排逻辑,使其均匀的分散
36 if (oldTab != null) {
37 // 遍历新数组的所有桶下标
38 for (int j = 0; j < oldCap; ++j) {
39 Node<K,V> e;
40 if ((e = oldTab[j]) != null) {
41 // 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收
42 oldTab[j] = null;
43 // 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
44 if (e.next == null)
45 // 用同样的hash映射算法把该元素加入新的数组
46 newTab[e.hash & (newCap - 1)] = e;
47 // 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
48 else if (e instanceof TreeNode)
49 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
50 // e是链表的头并且e.next!=null,那么处理链表中元素重排
51 else { // preserve order
52 // loHead,loTail 代表扩容后不用变换下标,见注1
53 Node<K,V> loHead = null, loTail = null;
54 // hiHead,hiTail 代表扩容后变换下标,见注1
55 Node<K,V> hiHead = null, hiTail = null;
56 Node<K,V> next;
57 // 遍历链表
58 do {
59 next = e.next;
60 if ((e.hash & oldCap) == 0) {
61 if (loTail == null)
62 // 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead
63 // 代表下标保持不变的链表的头元素
64 loHead = e;
65 else
66 // loTail.next指向当前e
67 loTail.next = e;
68 // loTail指向当前的元素e
69 // 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素
时,
70 // 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....
71 // 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。
72 loTail = e;
73 }
74 else {
75 if (hiTail == null)
76 // 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素
77 hiHead = e;
78 else
79 hiTail.next = e;
80 hiTail = e;
81 }
82 } while ((e = next) != null);
83 // 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。
84 if (loTail != null) {
85 loTail.next = null;
86 newTab[j] = loHead;
87 }
88 if (hiTail != null) {
89 hiTail.next = null;
90 newTab[j + oldCap] = hiHead;
91 }
92 }
93 }
94 }
95 }
96 return newTab;
97 }
HashMap 是怎么解决哈希冲突的?
答:在解决这个问题之前,我们首先需要知道什么是哈希冲突,而在了解哈希冲突之前我们还要知道什 么是哈希才行;什么是哈希?
Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成 固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通 常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入 值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也 不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。
什么是哈希冲突?
当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希碰 撞)。
HashMap 的数据结构
在 Java 中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除 困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各 自的优势,使用一种叫做链地址法的方式可以解决哈希冲突:
这样我们就可以将拥有相同哈希值的对象(img)组织成一个链表放在 hash 值所对应的 bucket 下,但相比 于 hashCode 返回的 int 类型,我们 HashMap 初始的容量大小 DEFAULT_INITIAL_CAPACITY = 1 << 4(即 2 的四次方 16)要远小于 int 类型的范围,所以我们如果只是单纯的用 hashCode 取余来获取对应的 bucket 这将会大大增加哈希碰撞的概率,并且最坏情况下还会将 HashMap 变成一个单链表,所以我们还 需要对 hashCode 作一定的优化 hash()函数
上面提到的问题,主要是因为如果使用 hashCode 取余,那么相当于参与运算的只有 hashCode 的低位, 高位是没有起到任何作用的,所以我们的思路就是让 hashCode 取值出的高位也参与运算,进一步降低 hash 碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在 JDK 1.8 中的 hash()函数如下:
1 static final int hash(Object key) {
2 int h;
3 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与自己右移16位
进行异或运算(高低位异或)
4 }
这比在 JDK 1.7 中,更为简洁,相比在 1.7 中的 4 次位运算,5 次异或运算(9 次扰动),在 1.8 中,只进行 了 1 次位运算和 1 次异或运算(2 次扰动);
通过上面的链地址法(使用散列表)和扰(img)动函数我们成功让我们的数据分布更平均,哈希碰撞减 少,但是当我们的 HashMap 中存在大量数据时,加入我们某个 bucket 下对应的链表有 n 个元素,那么遍 历时间复杂度就为 O(n),为了针对这个问题,JDK1.8 在 HashMap 中新增了红黑树的数据结构,进一步使 得遍历复杂度降低至 O(logn);总结 简单总结一下 HashMap 是使用了哪些方法来有效解决哈希冲突的:
- 使用链地址法(使用散列表)来链接拥有相同 hash 值的数据;
- 使用 2 次扰动函数(hash 函数)来降低哈希冲突的概率,使得数据分布更平均;
- 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快;
能否使用任何类作为 Map 的 key?
可以使用任何类作为 Map 的 key,然而在使用之前,需要考虑以下几点: 如果类重写了 equals() 方 法,也应该重写 hashCode() 方法。类的所有实例需要遵循与 equals() 和 hashCode() 相关的规则。
如果一个类没有使用 equals(),不应该在 hashCode() 中使用它。
用户自定义 Key 类 佳实践是使之为不可变的,这样 hashCode() 值可以被缓存起来,拥有更好的性能。 不可变的类也可以确保 hashCode() 和 equals() 在未来不会改变,这样就会解决与可变相关的问题了。
为什么 HashMap 中 String、Integer 这样的包装类适合作为 K?
答:String、Integer 等包装类的特性能够保证 Hash 值的不可更改性和计算准确性,能够有效的减少 Hash 碰撞的几率
- 都是 final 类型,即不可变性,保证 key 的不可更改性,不会存在获取 hash 值不同的情况 内部已重写了 equals()、hashCode()等方法,遵守了 HashMap 内部的规范(不清楚可以去上面看看 putValue 的过程),不容易出现 Hash 值计算错误的情况
如果使用 Object 作为 HashMap 的 Key,应该怎么办呢?
答:重写 hashCode()和 equals()方法
- 重写 hashCode()是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一 个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的 Hash 碰撞;
- 重写 equals()方法,需要遵守自反性、对称性、传递性、一致性以及对 于任何非 null 的引用值 x,x.equals(null)必须返回 false 的这几个特性,目的是为了保证 key 在哈希表中的 唯一性
HashMap 为什么不直接使用 hashCode()处理后的哈希 值直接作为 table 的下标?
答:hashCode()方法返回的是 int 整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有 40 亿个映射空间,而 HashMap 的容量范围是在 16(初始化默认值)~2 ^ 30,HashMap 通常情况下是取不到 大值的,并且 设备上也难以提供这么多的存储空间,从而导致通过 hashCode()计算出的哈希值可能不在数组大小范围 内,进而无法匹配存储位置;
那怎么解决呢?
- HashMap 自己实现了自己的 hash()方法,通过两次扰动使得它自己的哈希值高低位自行进行异或运 算,降低哈希碰撞概率也使得数据分布更平均;
- 在保证数组长度为 2 的幂次方的时候,使用 hash()运算之后的值与运算 (&)(数组长度 - 1)来获取数组下标的方式进行存储,这样一来是比取 余操作更加有效率,二来也是因为只有当数组长度为 2 的幂次方时,h& (length-1)才等价于 h%length,三来解决了“哈希值与数组大小范围不匹配"的问题
HashMap 的长度为什么是 2 的幂次方
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大 致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。
这个算法应该如何设计呢?我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操 作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。” 并且 采用二进制位操作 &,相对 于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。 那为什么是两次扰动呢?答:这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组 存储下标位置的随机性&均匀性, 终减少 Hash 冲突,两次就够了,已经达到了高位低位同时参与运算 的目的
HashMap 与 HashTable 有什么区别?
- 线程安全: HashMap 是非线程安全的,HashTable 是线程安全的; HashTable 内部的方法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
- 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被 淘汰,不要在代码中使用它;
- 对 Null key 和 Null value 的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一 个或多个键所对应的值为 null。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛 NullPointerException。
- 初始容量大小和每次扩充容量大小的不同 :
- ① 创建时如果不指定容量初始值,Hashtable 默认的 初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后 每次扩充,容量变为原来的 2 倍。
- ② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你 给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小。也就是说 HashMap 总是使用 2 的幂作为 哈希表的大小,后面会介绍到为什么是 2 的幂次方。
- 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈 值(默认为 8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
- 推荐使用:在 Hashtable 的类注释可以看到,Hashtable 是保留类不建议使用,推荐在单线程环境 下使用 HashMap 替代,如果需要多线程使用则用 ConcurrentHashMap 替代。
如何决定使用 HashMap 还是 TreeMap?
对于在 Map 中插入、删除和定位元素这类操作,HashMap 是 好的选择。然而,假如你需要对一个有序的 key 集合进行遍历,TreeMap 是更好的选择。基于你的 collection 的大小, 也许向 HashMap 中添加元素会更快,将 map 换为 TreeMap 进行有序 key 的遍历
HashMap 和 ConcurrentHashMap 的区别
- ConcurrentHashMap 对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用 lock 锁进 行保护,相对于 HashTable 的 synchronized 锁的粒度更精细了一些,并发性能更好,而 HashMap 没有锁机制,不是线程安全的。(JDK1.8 之后 ConcurrentHashMap 启了一种全新的方式实现,利用 CAS 算法。)
- HashMap 的键值对允许有 null,但是 ConCurrentHashMap 都不允许。
ConcurrentHashMap 和 Hashtable 的区别?
答:ConcurrentHashMap 结合了 Hash(img)Map 和 HashTable 二者的优势。 HashMap 没有考虑同 步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。 ConcurrentHashMap 锁的方式是稍微细粒度的。
ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。
底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组 +链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑 二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组 是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
实现线程安全的方式(重要):
① 在 JDK1.7 的时候, ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一 部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配 16 个 Segment,比 Hashtable 效率提高 16 倍。)
到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化) 整个看起来就像是优 化过且线程安全的 HashMap,虽然在 JDK1.8 中还 能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
②Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法 时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使 用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
两者的对比图:
HashTable:
ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?
JDK1.7
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据 时,其他段的数据也能被其他线程访问。
在 JDK1.7 中,ConcurrentHashMap 采用 Segment + HashEntry 的方式进行实 现,结构如下: 一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种数 组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每 个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先 获得对应的 Segment 的锁。
- 该类包含两个静态内部类 HashE(img)ntry 和 Segment ;前者用来封装映射表的键值对,后者用来 充当锁的角色;
- Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个 HashEntry 数组里得元素, 当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。
JDK1.8
在 JDK1.8 中,放弃了 Segment 臃肿的设计,取而代之的是采用 Node + CAS + Synchronized 来保证并发 安全进行实现,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产 生并发,效率又提升 N 倍。 结构如下:
看插入元素过程(建议去看看源码): 如果相应位置的 Node 还没有初始化,则调用 CAS 插入相应的数据;
1 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
2 if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
3 break; // no lock when adding to empty bin
4 }
如果相应位置的 Node 不为空,且当前该节点不处于移动状态,则对该节点加 synchronized 锁,如果该节点的 hash 不小于 0,则遍历链表更新节点或插入新节点;
1 if (fh >= 0) {
2 binCount = 1;
3 for (Node<K,V> e = f;; ++binCount) {
4 K ek;
5 if (e.hash == hash &&
6 ((ek = e.key) == key ||
7 (ek != null && key.equals(ek)))) {
8 oldVal = e.val;
9 if (!onlyIfAbsent)
10 e.val = value;
11 break;
12 }
13 Node<K,V> pred = e;
14 if ((e = e.next) == null) {
15 pred.next = new Node<K,V>(hash, key, value, null);
16 break;
17 }
18 }
19 }
- 如果该节点是 TreeBin 类型的节点,说明是红黑树结构,则通过 putTreeVal 方法往红黑树中插入节 点;如果 binCount 不为 0,说明 put 操作对数据产生了影响,如果当前链表的个数达到 8 个,则通过 treeifyBin 方法转化为红黑树,如果 oldVal 不为空,说明是一次更新操作,没有对元素个数产生影 响,则直接返回旧值;
- 如果插入的是一个新节点,则执行 addCount()方法尝试更新元素个数 baseCount;
List
Java 的 List 是非常常用的数据类型。List 是有序的 Collection。Java List 一共三个实现类:分别是 ArrayList、Vector 和 LinkedList。
ArrayList(数组)
ArrayList 是最常用的 List 实现类,内部是通过数组实现的,它允许对元素进行快速随机访问。数组的缺 点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要将已经有数组的数据复制 到新的存储空间中。当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代 价比较高。因此,它适合随机查找和遍历,不适合插入和删除。
Vector(数组实现、线程同步)
Vector 与 ArrayList 一样,也是通过数组实现的,不同的是它支持线程的同步,即某一时刻只有一 个线程能够写 Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问 它比访问 ArrayList 慢。
LinkList(链表)
LinkedList 是用链表结构存储数据的,很适合数据的动态插入和删除,随机访问和遍历速度比较慢。另 外,他还提供了 List 接口中没有定义的方法,专门用于操作表头和表尾元素,可以当作堆栈、队列和双 向队列使用。
辅助工具类
Array 和 ArrayList 有何区别?
- Array 可以存储基本数据类型和对象,ArrayList 只能存储对象。
- Array 是指定固定大小的,而 ArrayList 大小是自动扩展的。
- Array 内置方法没有 ArrayList 多,比如 addAll、removeAll、iteration 等方法只有 ArrayList 有。
对于基本类型数据,集合使用自动装箱来减少编码工作量。但是,当处理固定大小的基本数据类型的时 候,这种方式相对比较慢。
如何实现 Array 和 List 之间的转换?
- Array 转 List: Arrays. asList(array) ;
- List 转 Array:List 的 toArray() 方法。
comparable 和 comparator 的区别?
- comparable 接口实际上是出自 java.lang 包,它有一个 compareTo(Object obj)方法用来排序
- comparator 接口实际上是出自 java.util 包,它有一个 compare(Object obj1, Object obj2)方法用 来排序
一般我们需要对一个集合使用自定义排序时,我们就要重写 compareTo 方法或 compare 方法,当我们 需要对某一个集合实现两种排序方式,比如一个 song 对象中的歌名和歌手名分别采用一种排序方法的 话,我们可以重写 compareTo 方法和使用自制的 Comparator 方法或者以两个 Comparator 来实现歌名 排序和歌星名排序,第二种代表我们只能使用两个参数版的 Collections.sort().
Collection 和 Collections 有什么区别?
java.util.Collection 是一个集合接口(集合类的一个顶级接口)。它提供了对集合对象进行基本操 作的通用接口方法。Collection 接口在 Java 类库中有很多具体的实现。Collection 接口的意义是为 各种具体的集合提供了 大化的统一操作方式,其直接继承接口有 List 与 Set。
Collections 则是集合类的一个工具类/帮助类,其中提供了一系列静态方法,用于对集合中元素进 行排序、搜索以及线程安全等各种操作。
TreeMap 和 TreeSet 在排序时如何比较元素? Collections 工具类中的 sort()方法如何比较元素?
TreeSet 要求存放的对象所属的类必须实现 Comparable 接口,该接口提供了比较元素的 compareTo() 方法,当插入元素时会回调该方法比较元素的大小。
TreeMap 要求存放的键值对映射的键必须实现 Comparable 接口从而根据键对元素进 行排 序。
Collections 工具类的 sort 方法有两种重载的形式,
- 第一种要求传入的待排序容器中存放的对象比较实现 Comparable 接口以实现元素的比较;
- 第二种不强制性的要求容器中的元素必须可比较,但是要求传入第二个参数,参数是 Comparator 接口 的子类型(需要重写 compare 方法实现元素的比较),相当于一个临时定义的排序规则,其实就是通 过接口注入比较元素大小的算法,也是对回调模式的应用(Java 中对函数式编程的支持)。
Vector,ArrayList, LinkedList 的区别是什么?
答:
- Vector、ArrayList 都是以类似数组的形式存储在内存中,LinkedList 则以链表的形式进行存储。
- List 中的元素有序、允许有重复的元素,Set 中的元素无序、不允许有重复元素。
- Vector 线程同步,ArrayList、LinkedList 线程不同步。
- LinkedList 适合指定位置插入、删除操作,不适合查找;ArrayList、Vector 适合查找,不适合指定 位置的插入、删除操作。
- ArrayList 在元素填满容器时会自动扩充容器大小的 50%,而 Vector 则是 100%,因此 ArrayList 更节 省空间。
HashTable, HashMap,TreeMap 区别?
答:
- HashTable 线程同步,HashMap 非线程同步。
- HashTable 不允许<键,值>有空值,HashMap 允许<键,值>有空值。
- HashTable 使用 Enumeration,HashMap 使用 Iterator。
- HashTable 中 hash 数组的默认大小是 11,增加方式的 old*2+1,HashMap 中 hash 数组的默认大小 是 16,增长方式一定是 2 的指数倍。
- TreeMap 能够把它保存的记录根据键排序,默认是按升序排序。
HashMap 的数据结构
jdk1.8 之前 list + 链表 jdk1.8 之后 list + 链表(当链表长度到 8 时,转化为红黑树)
HashMap 的扩容因子
默认 0.75,也就是会浪费 1/4 的空间,达到扩容因子时,会将 list 扩容一倍,0.75 是时间与空间一个平衡 值;
多线程修改 HashMap
多线程同时写入,同时执行扩容操作,多线程扩容可能死锁、丢数据;可以对 HashMap 加入同步锁 Collections.synchronizedMap(hashMap),但是效率很低,因为该锁是互斥锁,同一时刻只能有一个线 程执行读写操作,这时候应该使用 ConcurrentHashMap 注意:在使用 Iterator 遍历的时候,LinkedHashMap 会产生 java.util.ConcurrentModificationException 。
扩展 HashMap 增加双向链表的实现,号称是最占内存的数据结构。支持 iterator()时按 Entry 的插入 顺序来排序(但是更新不算, 如果设置 accessOrder 属性为 true,则所有读写访问都算)。实现上是 在 Entry 上再增加属性 before/after 指针,插入时把自己加到 Header Entry 的前面去。如果所有读 写访问都要排序,还要把前后 Entry 的 before/after 拼接起来以在链表中删除掉自己。
Java 中的队列都有哪些,有什么区别
- ArrayDeque, (数组双端队列)
- PriorityQueue, (优先级队列)
- ConcurrentLinkedQueue, (基于链表的并发队列)
- DelayQueue, (延期阻塞队列)(阻塞队列实现了 BlockingQueue 接口)
- ArrayBlockingQueue, (基于数组的并发阻塞队列)
- LinkedBlockingQueue, (基于链表的 FIFO 阻塞队列)
- LinkedBlockingDeque, (基于链表的 FIFO 双端阻塞队列)
- PriorityBlockingQueue, (带优先级的无界阻塞队列)
- SynchronousQueue (并发同步阻塞队列)
反射中,Class.forName 和 classloader 的区别
java 中 class.forName()和 classLoader 都可用来对类进行加载。
class.forName()前者除了将类的.class 文件加载到 jvm 中之外,还会对类进行解释,执行类中的 static 块。
而 classLoader 只干一件事情,就是将.class 文件加载到 jvm 中,不会执行 static 中的内容,只有在 newInstance 才会去执行 static 块。
Class.forName(name, initialize, loader)带参函数也可控制是否加载 static 块。并且只有调用了 newInstance()方法采用调用构造函数,创建类的对象
备注: 根据运行结果,可以看到,classloader 并没有执行静态代码块,如开头的理论所说。 而下面的 Class.forName 则是夹在完之后,就里面执行了静态代码块,可以看到,2 个类,line 和 point 的 静态代码块执行结果是一起的,然后才是各自的打印结果。 也说明上面理论是 OK 的。 更新于 2017/06/20 因为看到有小伙伴有疑问,我就把自己以前的代码拿出来再次测试一遍,发现结果仍然是相同的。 但是,因为我的 Javabean model 又经历了其他的测试,所以,两个 model 内部的代码稍有变化, 然后,还真就测试出来了不一样的地方。 这估计是其他理论所没有的。具体看下面的代码吧。 只是修改了 Line 的代码,添加了几个静态的方法和变量。
//Class.forName(String className) 这是1.8的源码
public static Class<?> forName(String className) throws
ClassNotFoundException {
Class<?> caller = Reflection.getCallerClass();
return forName0(className, true, ClassLoader.getClassLoader(caller),
caller);
} /
/注意第二个参数,是指Class被loading后是不是必须被初始化。 不初始化就是不执行static的代
码即静态代码
package com.lxk.Reflect;
/**
* Created by lxk on 2017/2/21
*/
public class Line {
static {
System.out.println("静态代码块执行:loading line");
}
}
p
ackage com.lxk.Reflect;
/**
* Created by lxk on 2017/2/21
*/
public class Point {
static {
System.out.println("静态代码块执行:loading point");
}
}
p
ackage com.lxk.Reflect;
/**
* Class.forName和classloader的区别
* <p>
* Created by lxk on 2017/2/21
*/
public class ClassloaderAndForNameTest {
public static void main(String[] args) {
String wholeNameLine = "com.lxk.Reflect.Line";
String wholeNamePoint = "com.lxk.Reflect.Point";
System.out.println("下面是测试Classloader的效果");
testClassloader(wholeNameLine, wholeNamePoint);
System.out.println("----------------------------------");
System.out.println("下面是测试Class.forName的效果");
testForName(wholeNameLine, wholeNamePoint);
} /
**
* classloader
*/
private static void testClassloader(String wholeNameLine, String
wholeNamePoint) {
Class<?> line;
Class<?> point;
ClassLoader loader = ClassLoader.getSystemClassLoader();
try {
line = loader.loadClass(wholeNameLine);
point = loader.loadClass(wholeNamePoint);
//demo =
ClassloaderAndForNameTest.class.getClassLoader().loadClass(wholeNamePoint);//这个
也是可以的
System.out.println("line " + line.getName());
System.out.println("point " + point.getName());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
} /
**
* Class.forName
*/
private static void testForName(String wholeNameLine, String wholeNamePoint)
{
try {
Class line = Class.forName(wholeNameLine);
Class point = Class.forName(wholeNamePoint);
System.out.println("line " + line.getName());
System.out.println("point " + point.getName());
} catch (Exception e) {
e.printStackTrace();
}
}
}
讲讲 IO 里面的常见类,字节流、字符流、接口、实现类、方法阻塞
Java IO 流详解(二)——IO 流的框架体系
一、IO 流的概念
Java 的 IO 流是实现输入/输出的基础,它可以方便地实现数据的输入/输出操作,在 Java 中把不同的输入/ 输出源抽象表述为"流"。流是一组有顺序的,有起点和终点的字节集合,是对数据传输的总称或抽象。 即数据在两设备间的传输称为流,流的本质是数据传输,根据数据传输特性将流抽象为各种类,方便更 直观的进行数据操作。
流有输入和输出,输入时是流从数据源流向程序。输出时是流从程序传向数据源,而数据源可以是内 存,文件,网络或程序等。
二、IO 流的分类
1.输入流和输出流
根据数据流向不同分为:输入流和输出流。
- 输入流:只能从中读取数据,而不能向其写入数据。
- 输出流:只能向其写入数据,而不能从中读取数据。
如下如所示:对程序而言,向右的箭头,表示输入,向左的箭头,表示输出。
2.字节流和字符流
字节流和字符流和用法几乎完全一样,区别在于字节流和字符流所操作的数据单元不同。 字符流的由来: 因为数据编码的不同,而有了对字符进行高效操作的流对象。本质其实就是基于字节流 读取时,去查了指定的码表。字节流和字符流的区别:
- (1)读写单位不同:字节流以字节(8bit)为单位,字符流以字符为单位,根据码表映射字符,一次 可能读多个字节。
- (2)处理对象不同:字节流能处理所有类型的数据(如图片、avi 等),而字符流只能处理字符类型的 数据。
只要是处理纯文本数据,就优先考虑使用字符流。 除此之外都使用字节流。
3.节点流和处理流
按照流的角色来分,可以分为节点流和处理流。 可以从/向一个特定的 IO 设备(如磁盘、网络)读/写数据的流,称为节点流,节点流也被成为低级流。 处理流是对一个已存在的流进行连接或封装,通过封装后的流来实现数据读/写功能,处理流也被称为 高级流。
//节点流,直接传入的参数是IO设备
FileInputStream fis = new FileInputStream("test.txt");
//处理流,直接传入的参数是流对象
BufferedInputStream bis = new BufferedInputStream(fis);
当使用处理流进行输入/输出时,程序并不会直接连接到实际的数据源,没有和实际的输入/输出节点连 接。使用处理流的一个明显好处是,只要使用相同的处理流,程序就可以采用完全相同的输入/输出代码 来访问不同的数据源,随着处理流所包装节点流的变化,程序实际所访问的数据源也相应地发生变化。 实际上,Java 使用处理流来包装节点流是一种典型的装饰器设计模式,通过使用处理流来包装不同的节 点流,既可以消除不同节点流的实现差异,也可以提供更方便的方法来完成输入/输出功能。
三、IO 流的四大基类
根据流的流向以及操作的数据单元不同,将流分为了四种类型,每种类型对应一种抽象基类。这四种抽 象基类分别为:InputStream,Reader,OutputStream 以及 Writer。四种基类下,对应不同的实现类,具 有不同的特性。在这些实现类中,又可以分为节点流和处理流。下面就是整个由着四大基类支撑下,整 个 IO 流的框架图。
InputStream,Reader,OutputStream 以及 Writer,这四大抽象基类,本身并不能创建实例来执行输入/输 出,但它们将成为所有输入/输出流的模版,所以它们的方法是所有输入/输出流都可以使用的方法。类 似于集合中的 Collection 接口。
1.InputStream
InputStream 是所有的输入字节流的父类,它是一个抽象类,主要包含三个方法:
//读取一个字节并以整数的形式返回(0~255),如果返回-1已到输入流的末尾。
int read() ;
//读取一系列字节并存储到一个数组buffer,返回实际读取的字节数,如果读取前已到输入流的末尾返
回-1。
int read(byte[] buffer) ;
//读取length个字节并存储到一个字节数组buffer,从off位置开始存,最多len, 返回实际读取的字节
数,如果读取前以到输入流的末尾返回-1。
int read(byte[] buffer, int off, int len) ;
2.Reader
Reader 是所有的输入字符流的父类,它是一个抽象类,主要包含三个方法:
//读取一个字符并以整数的形式返回(0~255),如果返回-1已到输入流的末尾。
int read() ;
//读取一系列字符并存储到一个数组buffer,返回实际读取的字符数,如果读取前已到输入流的末尾返
回-1。
int read(char[] cbuf) ;
//读取length个字符,并存储到一个数组buffer,从off位置开始存,最多读取len,返回实际读取的字符
数,如果读取前以到输入流的末尾返回-1。
int read(char[] cbuf, int off, int len)
对比 InputStream 和 Reader 所提供的方法,就不难发现两个基类的功能基本一样的,只不过读取的数据 单元不同。
在执行完流操作后,要调用 close() 方法来关系输入流,因为程序里打开的 IO 资源不属于内存资源,垃 圾回收机制无法回收该资源,所以应该显式关闭文件 IO 资源。
除此之外,InputStream 和 Reader 还支持如下方法来移动流中的指针位置:
//在此输入流中标记当前的位置
//readlimit - 在标记位置失效前可以读取字节的最大限制。
void mark(int readlimit)
// 测试此输入流是否支持 mark 方法
boolean markSupported()
// 跳过和丢弃此输入流中数据的 n 个字节/字符
long skip(long n)
//将此流重新定位到最后一次对此输入流调用 mark 方法时的位置
void reset()
3.OutputStream
OutputStream 是所有的输出字节流的父类,它是一个抽象类,主要包含如下四个方法:
//向输出流中写入一个字节数据,该字节数据为参数b的低8位。
void write(int b) ;
//将一个字节类型的数组中的数据写入输出流。
void write(byte[] b);
//将一个字节类型的数组中的从指定位置(off)开始的,len个字节写入到输出流。
void write(byte[] b, int off, int len);
//将输出流中缓冲的数据全部写出到目的地。
void flush();
4.Writer
Writer 是所有的输出字符流的父类,它是一个抽象类,主要包含如下六个方法:
//向输出流中写入一个字符数据,该字节数据为参数b的低16位。
void write(int c);
//将一个字符类型的数组中的数据写入输出流,
void write(char[] cbuf)
//将一个字符类型的数组中的从指定位置(offset)开始的,length个字符写入到输出流。
void write(char[] cbuf, int offset, int length);
//将一个字符串中的字符写入到输出流。
void write(String string);
//将一个字符串从offset开始的length个字符写入到输出流。
void write(String string, int offset, int length);
//将输出流中缓冲的数据全部写出到目的地。
void flush()
可以看出,Writer 比 OutputStream 多出两个方法,主要是支持写入字符和字符串类型的数据。 使用 Java 的 IO 流执行输出时,不要忘记关闭输出流,关闭输出流除了可以保证流的物理资源被回收之 外,还能将输出流缓冲区的数据 flush 到物理节点里(因为在执行 close()方法之前,自动执行输出流的 flush()方法) 以上内容就是整个 IO 流的框架介绍
讲讲 NIO
NIO 技术概览
NIO(Non-blocking I/O,在 Java 领域,也称为 New I/O),是一种同步非阻塞的 I/O 模型,也是 I/O 多路 复用的基础,已经被越来越多地应用到大型应用服务器,成为解决高并发与大量连接、I/O 处理问题的有 效方式。
IO 模型的分类
按照《Unix 网络编程》的划分,I/O 模型可以分为:阻塞 I/O 模型、非阻塞 I/O 模型、I/O 复用模型、信号 驱动式 I/O 模型和异步 I/O 模型,按照 POSIX 标准来划分只分为两类:同步 I/O 和异步 I/O。 如何区分呢?首先一个 I/O 操作其实分成了两个步骤:发起 IO 请求和实际的 IO 操作。同步 I/O 和异步 I/O 的 区别就在于第二个步骤是否阻塞,如果实际的 I/O 读写阻塞请求进程,那么就是同步 I/O,因此阻塞 I/O、 非阻塞 I/O、I/O 复用、信号驱动 I/O 都是同步 I/O,如果不阻塞,而是操作系统帮你做完 I/O 操作再将结果 返回给你,那么就是异步 I/O。
阻塞 I/O 和非阻塞 I/O 的区别在于第一步,发起 I/O 请求是否会被阻塞,如果阻塞直到完成那么就是传统的 阻塞 I/O,如果不阻塞,那么就是非阻塞 I/O。
- 阻塞 I/O 模型 :在 linux 中,默认情况下所有的 socket 都是 blocking,一个典型的读操作流程大概是 这样:
- 非阻塞 I/O 模型:linux 下,可以通过设置 socket 使其变为 non-blocking。当对一个 non-blocking socket 执行读操作时,流程是这个样子:
- I/O 复用模型:我们可以调用 select 或 poll ,阻塞在这两个系统调用中的某一个之上,而不是真 正的 IO 系统调用上:
- 信号驱动式 I/O 模型:我们可以用信号,让内核在描述符就绪时发送 SIGIO 信号通知我们:
- 异步 I/O 模型:用户进程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从内核 的角度,当它受到一个 asynchronousread 之后,首先它会立刻返回,所以不会对用户进程产生任 何 block。然后,内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后, 内核会给用户进程发送一个 signal,告诉它 read 操作完成了:
从前面 I/O 模型的分类中,我们可以看出 AIO 的动机。阻塞模型需要在 I/O 操作开始时阻塞应用程序。 这意味着不可能同时重叠进行处理和 I/O 操作。非阻塞模型允许处理和 I/O 操作重叠进行,但是这需要 应用程序来检查 I/O 操作的状态。对于异步 I/O ,它允许处理和 I/O 操作重叠进行,包括 I/O 操作完成的 通知。除了需要阻塞之外,select 函数所提供的功能(异步阻塞 I/O)与 AIO 类似。不过,它是对通知 事件进行阻塞,而不是对 I/O 调用进行阻塞。
两种 IO 多路复用方案:Reactor 和 Proactor
一般地,I/O 多路复用机制都依赖于一个事件多路分离器(Event Demultiplexer)。分离器对象可将来自 事件源的 I/O 事件分离出来,并分发到对应的 read/write 事件处理器(Event Handler)。开发人员预先注 册需要处理的事件及其事件处理器(或回调函数);事件分离器负责将请求事件传递给事件处理器。 两个与事件分离器有关的模式是 Reactor 和 Proactor。Reactor 模式采用同步 I/O,而 Proactor 采用异步 I/O。在 Reactor 中,事件分离器负责等待文件描述符或 socket 为读写操作准备就绪,然后将就绪事件传 递给对应的处理器,最后由处理器负责完成实际的读写工作。 而在 Proactor 模式中,处理器或者兼任处理器的事件分离器,只负责发起异步读写操作。I/O 操作本身由 操作系统来完成。传递给操作系统的参数需要包括用户定义的数据缓冲区地址和数据大小,操作系统才 能从中得到写出操作所需数据,或写入从 socket 读到的数据。事件分离器捕获 I/O 操作完成事件,然后将 事件传递给对应处理器。比如,在 windows 上,处理器发起一个异步 I/O 操作,再由事件分离器等待
IOCompletion 事件。典型的异步模式实现,都建立在操作系统支持异步 API 的基础之上,我们将这种实 现称为“系统级”异步或“真”异步,因为应用程序完全依赖操作系统执行真正的 I/O 工作。 举个例子,将有助于理解 Reactor 与 Proactor 二者的差异,以读操作为例(写操作类似)。
在 Reactor 中实现读:
- 注册读就绪事件和相应的事件处理器;
- 事件分离器等待事件;
- 事件到来,激活分离器,分离器调用事件对应的处理器;
- 事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权。
在 Proactor 中实现读:
- 处理器发起异步读操作(注意:操作系统必须支持异步 I/O)。在这种情况下,处理器无视 I/O 就绪 事件,它关注的是完成事件;
- 事件分离器等待操作完成事件;
- 在分离器等待过程中,操作系统利用并行的内核线程执行实际的读操作,并将结果数据存入用户自 定义缓冲区,最后通知事件分离器读操作完成;
- 事件分离器呼唤处理器;
- 事件处理器处理用户自定义缓冲区中的数据,然后启动一个新的异步操作,并将控制权返回事件分 离器。
可以看出,两个模式的相同点,都是对某个 I/O 事件的事件通知(即告诉某个模块,这个 I/O 操作可以进 行或已经完成)。在结构上,两者的相同点和不同点如下:
- 相同点:demultiplexor 负责提交 I/O 操作(异步)、查询设备是否可操作(同步),然后当条件满 足时,就回调 handler;
- 不同点:异步情况下(Proactor),当回调 handler 时,表示 I/O 操作已经完成;同步情况下 (Reactor),回调 handler 时,表示 I/O 设备可以进行某个操作(can read or can write)。
传统 BIO 模型
BIO 是同步阻塞式 IO,通常在 while 循环中服务端会调用 accept 方法等待接收客户端的连接请求,一旦接 收到一个连接请求,就可以建立通信套接字在这个通信套接字上进行读写操作,此时不能再接收其他客 户端连接请求,只能等待同当前连接的客户端的操作执行完成。
如果 BIO 要能够同时处理多个客户端请求,就必须使用多线程,即每次 accept 阻塞等待来自客户端请 求,一旦受到连接请求就建立通信套接字同时开启一个新的线程来处理这个套接字的数据读写请求,然 后立刻又继续 accept 等待其他客户端连接请求,即为每一个客户端连接请求都创建一个线程来单独处 理。
我们看下传统的 BIO 方式下的编程模型大致如下:
这里之所以使用多线程,是因为 socket.accept()、inputStream.read()、outputStream.write()都是同步 阻塞的,当一个连接在处理 I/O 的时候,系统是阻塞的,如果是单线程的话在阻塞的期间不能接受任何请 求。所以,使用多线程,就可以让 CPU 去处理更多的事情。其实这也是所有使用多线程的本质: 利用多核。当 I/O 阻塞系统,但 CPU 空闲的时候,可以利用多线程使用 CPU 资源。 使用线程池能够让线程的创建和回收成本相对较低。在活动连接数不是特别高(小于单机 1000)的情况 下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系 统的过载、限流等问题。线程池可以缓冲一些过多的连接或请求。 但这个模型最本质的问题在于,严重依赖于线程。但线程是很”贵”的资源,主要表现在:
- 线程的创建和销毁成本很高,在 Linux 这样的操作系统中,线程本质上就是一个进程。创建和销毁 都是重量级的系统函数;
- 线程本身占用较大内存,像 Java 的线程栈,一般至少分配 512K ~ 1M 的空间,如果系统中的线程数 过千,恐怕整个 JVM 的内存都会被吃掉一半;
- 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统 调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现 往往是系统 load 偏高、CPU sy 使用率特别高(超过 20%以上),导致系统几乎陷入不可用的状态;
- 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或 CPU 核心数,一旦线程数量高但外部 网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载 压力过大。 所以,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。随着移动端应用的兴起和各 种网络游戏的盛行,百万级长连接日趋普遍,此时,必然需要一种更高效的 I/O 处理模型
NIO 的实现原理
NIO 本身是基于事件驱动思想来完成的,其主要想解决的是 BIO 的大并发问题,即在使用同步 I/O 的网络 应用中,如果要同时处理多个客户端请求,或是在客户端要同时和多个服务器进行通讯,就必须使用多 线程来处理。也就是说,将每一个客户端请求分配给一个线程来单独处理。这样做虽然可以达到我们的 要求,但同时又会带来另外一个问题。由于每创建一个线程,就要为这个线程分配一定的内存空间(也 叫工作存储器),而且操作系统本身也对线程的总数有一定的限制。如果客户端的请求过多,服务端程 序可能会因为不堪重负而拒绝客户端的请求,甚至服务器可能会因此而瘫痪。
NIO 基于 Reactor,当 socket 有流可读或可写入 socket 时,操作系统会相应的通知应用程序进行处理,应 用再将流读取到缓冲区或写入操作系统。
也就是说,这个时候,已经不是一个连接就要对应一个处理线程了,而是有效的请求,对应一个线程, 当连接没有数据时,是没有工作线程来处理的。
下面看下代码的实现:
NIO 服务端代码(新建连接
//获取一个ServerSocket通道
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.socket().bind(new InetSocketAddress(port));
//获取通道管理器
selector = Selector.open();
//将通道管理器与通道绑定,并为该通道注册SelectionKey.OP_ACCEPT事件,
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
NIO 服务端代码(监听):
while(true){
//当有注册的事件到达时,方法返回,否则阻塞。
selector.select();
for(SelectionKey key : selector.selectedKeys()){
if(key.isAcceptable()){
ServerSocketChannel server = (ServerSocketChannel)key.channel();
SocketChannel channel = server.accept();
channel.write(ByteBuffer.wrap(
new String("send message to client").getBytes()));
//在与客户端连接成功后,为客户端通道注册SelectionKey.OP_READ事件。
channel.register(selector, SelectionKey.OP_READ);
}else if(key.isReadable()){//有可读数据事件
SocketChannel channel = (SocketChannel)key.channel();
ByteBuffer buffer = ByteBuffer.allocate(10);
int read = channel.read(buffer);
byte[] data = buffer.array();
String message = new String(data);
System.out.println("receive message from client, size:"
+ buffer.position() + " msg: " + message);
}
}
}
NIO 模型示例如下:
- Acceptor 注册 Selector,监听 accept 事件;
- 当客户端连接后,触发 accept 事件;
- 服务器构建对应的 Channel,并在其上注册 Selector,监听读写事件;
- 当发生读写事件后,进行相应的读写处理。
Reactor 模型
有关 Reactor 模型结构,可以参考 Doug Lea 在 Scalable IO in Java 中的介绍。这里简单介绍一下 Reactor 模式的典型实现:
Reactor 单线程模型
这是最简单的单 Reactor 单线程模型。Reactor 线程负责多路分离套接字、accept 新连接,并分派请求到 处理器链中。该模型适用于处理器链中业务处理组件能快速完成的场景。不过,这种单线程模型不能充 分利用多核资源,所以实际使用的不多。
这个模型和上面的 NIO 流程很类似,只是将消息相关处理独立到了 Handler 中去了。 代码实现如下:
public class Reactor implements Runnable {
final Selector selector;
final ServerSocketChannel serverSocketChannel;
public static void main(String[] args) throws IOException {
new Thread(new Reactor(1234)).start();
} p
ublic Reactor(int port) throws IOException {
selector = Selector.open();
serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(port));
serverSocketChannel.configureBlocking(false);
SelectionKey key = serverSocketChannel.register(selector,
SelectionKey.OP_ACCEPT);
key.attach(new Acceptor());
} @
Override
public void run() {
while (!Thread.interrupted()) {
try {
selector.select();
Set<SelectionKey> selectionKeys = selector.selectedKeys();
for (SelectionKey selectionKey : selectionKeys) {
dispatch(selectionKey);
} s
electionKeys.clear();
} catch (IOException e) {
e.printStackTrace();
}
}
} p
rivate void dispatch(SelectionKey selectionKey) {
Runnable run = (Runnable) selectionKey.attachment();
if (run != null) {
run.run();
}
} c
lass Acceptor implements Runnable {
@Override
public void run() {
try {
SocketChannel channel = serverSocketChannel.accept();
if (channel != null) {
new Handler(selector, channel);
}
} catch (IOException e) {
e.printStackTrace();
}}
}
} c
lass Handler implements Runnable {
private final static int DEFAULT_SIZE = 1024;
private final SocketChannel socketChannel;
private final SelectionKey seletionKey;
private static final int READING = 0;
private static final int SENDING = 1;
private int state = READING;
ByteBuffer inputBuffer = ByteBuffer.allocate(DEFAULT_SIZE);
ByteBuffer outputBuffer = ByteBuffer.allocate(DEFAULT_SIZE);
public Handler(Selector selector, SocketChannel channel) throws IOException
{
this.socketChannel = channel;
socketChannel.configureBlocking(false);
this.seletionKey = socketChannel.register(selector, 0);
seletionKey.attach(this);
seletionKey.interestOps(SelectionKey.OP_READ);
selector.wakeup();
} @
Override
public void run() {
if (state == READING) {
read();
} else if (state == SENDING) {
write();
}
} c
lass Sender implements Runnable {
@Override
public void run() {
try {
socketChannel.write(outputBuffer);
} catch (IOException e) {
e.printStackTrace();
} i
f (outIsComplete()) {
seletionKey.cancel();
}
}} p
rivate void write() {
try {
socketChannel.write(outputBuffer);
} catch (IOException e) {
e.printStackTrace();
} w
hile (outIsComplete()) {
seletionKey.cancel();
}
} p
rivate void read() {
try {
socketChannel.read(inputBuffer);
if (inputIsComplete()) {
process();
System.out.println("接收到来自客户端(" +
socketChannel.socket().getInetAddress().getHostAddress()
+ ")的消息:" + new String(inputBuffer.array()));
seletionKey.attach(new Sender());
seletionKey.interestOps(SelectionKey.OP_WRITE);
seletionKey.selector().wakeup();
}
} catch (IOException e) {
e.printStackTrace();
}
} p
ublic boolean inputIsComplete() {
return true;
} p
ublic boolean outIsComplete() {
return true;
} p
ublic void process() {
// do something...
}
}
虽然上面说到 NIO 一个线程就可以支持所有的 IO 处理。但是瓶颈也是显而易见的。我们看一个客户端的 情况,如果这个客户端多次进行请求,如果在 Handler 中的处理速度较慢,那么后续的客户端请求都会 被积压,导致响应变慢!所以引入了 Reactor 多线程模型。
Reactor 多线程模型
相比上一种模型,该模型在处理器链部分采用了多线程(线程池):
Reactor 多线程模型就是将 Handler 中的 IO 操作和非 IO 操作分开,操作 IO 的线程称为 IO 线程,非 IO 操作的 线程称为工作线程。这样的话,客户端的请求会直接被丢到线程池中,客户端发送请求就不会堵塞。 可以将 Handler 做如下修改:
但是当用户进一步增加的时候,Reactor 会出现瓶颈!因为 Reactor 既要处理 IO 操作请求,又要响应连接 请求。为了分担 Reactor 的负担,所以引入了主从 Reactor 模型。
主从 Reactor 多线程模型
主从 Reactor 多线程模型是将 Reactor 分成两部分,mainReactor 负责监听 server socket,accept 新连 接,并将建立的 socket 分派给 subReactor。subReactor 负责多路分离已连接的 socket,读写网络数据, 对业务处理功能,其扔给 worker 线程池完成。通常,subReactor 个数上可与 CPU 个数等同:
AIO
与 NIO 不同,当进行读写操作时,只须直接调用 API 的 read 或 write 方法即可。这两种方法均为异步的, 对于读操作而言,当有流可读取时,操作系统会将可读的流传入 read 方法的缓冲区,并通知应用程序; 对于写操作而言,当操作系统将 write 方法传递的流写入完毕时,操作系统主动通知应用程序。 即可以理解为,read/write 方法都是异步的,完成后会主动调用回调函数。 在 JDK1.7 中,这部分内容被称作 NIO.2,主要在 java.nio.channels 包下增加了下面四个异步通道:
- AsynchronousSocketChannel
- AsynchronousServerSocketChannel
- AsynchronousFileChannel
- AsynchronousDatagramChannel
我们看一下 AsynchronousSocketChannel 中的几个方法:
其中的 read/write 方法,有的会返回一个 Future 对象,有的需要传入一个 CompletionHandler 对象, 该对象的作用是当执行完读取/写入操作后,直接该对象当中的方法进行回调。
对于 AsynchronousSocketChannel 而言,在 windows 和 linux 上的实现类是不一样的。 在 windows 上,AIO 的实现是通过 IOCP 来完成的,实现类是:
AIO 是一种接口标准,各家操作系统可以实现也可以不实现。在不同操作系统上在高并发情况下最好都 采用操作系统推荐的方式。Linux 上还没有真正实现网络方式的 AIO。
select 和 epoll 的区别
当需要读两个以上的 I/O 的时候,如果使用阻塞式的 I/O,那么可能长时间的阻塞在一个描述符上面,另 外的描述符虽然有数据但是不能读出来,这样实时性不能满足要求,大概的解决方案有以下几种:
- 使用多进程或者多线程,但是这种方法会造成程序的复杂,而且对与进程与线程的创建维护也需要 很多的开销(Apache 服务器是用的子进程的方式,优点可以隔离用户);
- 用一个进程,但是使用非阻塞的 I/O 读取数据,当一个 I/O 不可读的时候立刻返回,检查下一个是否 可读,这种形式的循环为轮询(polling),这种方法比较浪费 CPU 时间,因为大多数时间是不可 读,但是仍花费时间不断反复执行 read 系统调用;
- 异步 I/O,当一个描述符准备好的时候用一个信号告诉进程,但是由于信号个数有限,多个描述符 时不适用;
- 一种较好的方式为 I/O 多路复用,先构造一张有关描述符的列表(epoll 中为队列),然后调用一个 函数,直到这些描述符中的一个准备好时才返回,返回时告诉进程哪些 I/O 就绪。select 和 epoll 这 两个机制都是多路 I/O 机制的解决方案,select 为 POSIX 标准中的,而 epoll 为 Linux 所特有的。
它们的区别主要有三点:
- select 的句柄数目受限,在 linux/posix_types.h 头文件有这样的声明: #define __FD_SETSIZE 1024 表示 select 最多同时监听 1024 个 fd。而 epoll 没有,它的限制是最大的打开文件句柄数目;
- epoll 的最大好处是不会随着 FD 的数目增长而降低效率,在 selec 中采用轮询处理,其中的数据结构 类似一个数组的数据结构,而 epoll 是维护一个队列,直接看队列是不是空就可以了。epoll 只会对” 活跃”的 socket 进行操作—这是因为在内核实现中 epoll 是根据每个 fd 上面的 callback 函数实现的。那 么,只有”活跃”的 socket 才会主动的去调用 callback 函数(把这个句柄加入队列),其他 idle 状态 句柄则不会,在这点上,epoll 实现了一个”伪”AIO。但是如果绝大部分的 I/O 都是“活跃的”,每个 I/O 端口使用率很高的话,epoll 效率不一定比 select 高(可能是要维护队列复杂);
- 使用 mmap 加速内核与用户空间的消息传递。无论是 select,poll 还是 epoll 都需要内核把 FD 消息通知 给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll 是通过内核于用户空间 mmap 同一块内存实现的。
NIO 与 epoll
上文说到了 select 与 epoll 的区别,再总结一下 Java NIO 与 select 和 epoll:
- Linux2.6 之后支持 epoll
- windows 支持 select 而不支持 epoll
- 不同系统下 nio 的实现是不一样的,包括 Sunos linux 和 windows
- select 的复杂度为 O(N)
- select 有最大 fd 限制,默认为 1024
- 修改 sys/select.h 可以改变 select 的 fd 数量限制
- epoll 的事件模型,无 fd 数量限制,复杂度 O(1),不需要遍历 fd
以下代码基于 Java 8。 下面看下在 NIO 中 Selector 的 open 方法:
public static Selector open() throws IOException {
return SelectorProvider.provider().openSelector();
}
public static SelectorProvider provider() {
synchronized (lock) {
if (provider != null)
return provider;
return AccessController.doPrivileged(
new PrivilegedAction<SelectorProvider>() {
public SelectorProvider run() {
if (loadProviderFromProperty())
return provider;
if (loadProviderAsService())
return provider;
provider = sun.nio.ch.DefaultSelectorProvider.create();
return provider;
}
});
}
}
我们看到 create 方法中是通过区分操作系统来返回不同的 Provider 的。其中 SunOs 就是 Solaris 返回的是 DevPollSelectorProvider,对于 Linux,返回的 Provder 是 EPollSelectorProvider,其余操作系统,返回 的是 PollSelectorProvider。
Zero Copy
许多 web 应用都会向用户提供大量的静态内容,这意味着有很多数据从硬盘读出之后,会原封不动的通 过 socket 传输给用户。
这种操作看起来可能不会怎么消耗 CPU,但是实际上它是低效的:
- kernel 把从 disk 读数据;
- 将数据传输给 application;
- application 再次把同样的内容再传回给处于 kernel 级的 socket。
这种场景下,application 实际上只是作为一种低效的中间介质,用来把磁盘文件的数据传给 socket。 数据每次传输都会经过 user 和 kernel 空间都会被 copy,这会消耗 cpu,并且占用 RAM 的带宽。 传统的数据传输方式 像这种从文件读取数据然后将数据通过网络传输给其他的程序的方式其核心操作就是如下两个调用:
File.read(fileDesc,buf,len);
Socket.send(socket,buf,len);
上图展示了数据从文件到 socket 的内部流程。
下面看下用户态和内核态的切换过程:
步骤如下:
- read()的调用引起了从用户态到内核态的切换(看图二),内部是通过 sys_read()(或者类似的方 法)发起对文件数据的读取。数据的第一次复制是通过 DMA(直接内存访问)将磁盘上的数据复制 到内核空间的缓冲区中;
- 数据从内核空间的缓冲区复制到用户空间的缓冲区后,read()方法也就返回了。此时内核态又切换 回用户态,现在数据也已经复制到了用户地址空间的缓存中;
- socket 的 send()方法的调用又会引起用户态到内核的切换,第三次数据复制又将数据从用户空间缓 冲区复制到了内核空间的缓冲区,这次数据被放在了不同于之前的内核缓冲区中,这个缓冲区与数 据将要被传输到的 socket 关联;
- send()系统调用返回后,就产生了第四次用户态和内核态的切换。随着 DMA 单独异步的将数据从内 核态的缓冲区中传输到协议引擎发送到网络上,有了第四次数据复制。
NIO 存在的问题
使用 NIO != 高性能,当连接数<1000,并发程度不高或者局域网环境下 NIO 并没有显著的性能优势。 NIO 并没有完全屏蔽平台差异,它仍然是基于各个操作系统的 I/O 系统实现的,差异仍然存在。使用 NIO 做网络编程构建事件驱动模型并不容易,陷阱重重。 推荐大家使用成熟的 NIO 框架,如 Netty,MINA 等。解决了很多 NIO 的陷阱,并屏蔽了操作系统的差 异,有较好的性能和编程模型。 总结
最后总结一下 NIO 有哪些优势:
- 事件驱动模型
- 避免多线程
- 单线程处理多任务
- 非阻塞 I/O,I/O 读写不再阻塞
- 基于 block 的传输,通常比基于流的传输更高效
- 更高级的 IO 函数,Zero Copy
- I/O 多路复用大大提高了 Java 网络应用的可伸缩性和实用性
三个 channel 使用 ServerSocketChannel||SocketChannel||FileChannel
Java NIO 系列教程 FileChannel
Java NIO 中的 FileChannel 是一个连接到文件的通道。可以通过文件通道读写文件。
FileChannel 无法设置为非阻塞模式,它总是运行在阻塞模式下。
打开 FileChannel
在使用 FileChannel 之前,必须先打开它。但是,我们无法直接打开一个 FileChannel,需要通过使用一 个 InputStream、OutputStream 或 RandomAccessFile 来获取一个 FileChannel 实例。下面是通过 RandomAccessFile 打开 FileChannel 的示例:
public class FileChannelDemo {
public static void main(String[] args) throws IOException {
//打开fileChannel
RandomAccessFile raf = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel channel = raf.getChannel();
//从fileChannel读取数据
ByteBuffer buffer = ByteBuffer.allocate(48);
int read = channel.read(buffer);
System.out.println(read);
System.out.println(buffer);
String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while (buf.hasRemaining()) {
channel.write(buf);
}
channel.close();
}
}
FileChannel 的 size 方法
FileChannel 实例的 size()方法将返回该实例所关联文件的大小。如: long fileSize = channel.size();
FileChannel 的 truncate 方法
可以使用 FileChannel.truncate()方法截取一个文件。截取文件时,文件将中指定长度后面的部分将被删 除。如: channel.truncate(1024
); 这个例子截取文件的前 1024 个字节。
FileChannel 的 force 方法
FileChannel.force()方法将通道里尚未写入磁盘的数据强制写到磁盘上。出于性能方面的考虑,操作系 统会将数据缓存在内存中,所以无法保证写入到 FileChannel 里的数据一定会即时写到磁盘上。要保证这 一点,需要调用 force()方法。 force()方法有一个 boolean 类型的参数,指明是否同时将文件元数据(权限信息等)写到磁盘上。 下面的例子同时将文件数据和元数据强制写到磁盘上: channel.force(true
);
Java NIO 系列教程 SocketChannel
Java NIO 中的 SocketChannel 是一个连接到 TCP 网络套接字的通道。可以通过以下 2 种方式创建 SocketChannel:
- 打开一个 SocketChannel 并连接到互联网上的某台服务器。
- 一个新连接到达 ServerSocketChannel 时,会创建一个 SocketChannel。
打开 SocketChannel
下面是 SocketChannel 的打开方式: SocketChannel socketChannel = SocketChannel.open(); socketChannel.connect(new InetSocketAddress(
"http://jenkov.com"``, 80
));
关闭 SocketChannel
当用完 SocketChannel 之后调用 SocketChannel.close()关闭 SocketChannel: socketChannel.close();
从 SocketChannel 读取数据
要从 SocketChannel 中读取数据,调用一个 read()的方法之一。以下是例子:
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);
首先,分配一个 Buffer。从 SocketChannel 读取到的数据将会放到这个 Buffer 中。 然后,调用 SocketChannel.read()。该方法将数据从 SocketChannel 读到 Buffer 中。read()方法返回的 int 值表示读了多少字节进 Buffer 里。如果返回的是-1,表示已经读到了流的末尾(连接关闭了)。
写入 SocketChannel
写数据到 SocketChannel 用的是 SocketChannel.write()方法,该方法以一个 Buffer 作为参数。示例如 下
String newData = "New String to write to file..." +System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining()) {
channel.write(buf);
}
非阻塞模式
可以设置 SocketChannel 为非阻塞模式(non-blocking mode).设置之后,就可以在异步模式下调用 connect(), read() 和 write()了。
connect()
如果 SocketChannel 在非阻塞模式下,此时调用 connect(),该方法可能在连接建立之前就返回了。为了 确定连接是否建立,可以调用 finishConnect()的方法。像这样:
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("http://jenkov.com", 80));
while(! socketChannel.finishConnect() ){
//wait, or do something else...
}
write()
非阻塞模式下,write()方法在尚未写出任何内容时可能就返回了。所以需要在循环中调用 write()。前面 已经有例子了,这里就不赘述了。
read()
非阻塞模式下,read()方法在尚未读取到任何数据时可能就返回了。所以需要关注它的 int 返回值,它会告 诉你读取了多少字节。 非阻塞模式与选择器 非阻塞模式与选择器搭配会工作的更好,通过将一或多个 SocketChannel 注册到 Selector,可以询问选 择器哪个通道已经准备好了读取,写入等。Selector 与 SocketChannel 的搭配使用会在后面详讲。
Java NIO 系列教程 ServerSocketChannel
Java NIO 中的 ServerSocketChannel 是一个可以监听新进来的 TCP 连接的通道, 就像标准 IO 中的 ServerSocket 一样。ServerSocketChannel 类在 java.nio.channels 包中。 这里有个例子:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
while(true){
SocketChannel socketChannel =
serverSocketChannel.accept();
//do something with socketChannel...
}
打开 ServerSocketChannel
通过调用 ServerSocketChannel.open() 方法来打开 ServerSocketChannel.如: ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
关闭 ServerSocketChannel
通过调用 ServerSocketChannel.close() 方法来关闭 ServerSocketChannel. 如: serverSocketChannel.close();
监听新进来的连接
通过 ServerSocketChannel.accept() 方法监听新进来的连接。当 accept()方法返回的时候,它返回一个包 含新进来的连接的 SocketChannel。因此, accept()方法会一直阻塞到有新连接到达。 通常不会仅仅只监听一个连接,在 while 循环中调用 accept()方法. 如下面的例子:
while(true){
SocketChannel socketChannel =
serverSocketChannel.accept();
//do something with socketChannel...
}
当然,也可以在 while 循环中使用除了 true 以外的其它退出准则。
非阻塞模式
ServerSocketChannel 可以设置成非阻塞模式。在非阻塞模式下,accept() 方法会立刻返回,如果还没 有新进来的连接,返回的将是 null。 因此,需要检查返回的 SocketChannel 是否是 null.如:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
serverSocketChannel.configureBlocking(false);
while(true){
SocketChannel socketChannel =
serverSocketChannel.accept();
if(socketChannel != null){
//do something with socketChannel...
}
}
String 编码 UTF-8 和 GBK 的区别
- GBK 编码:是指中国的中文字符,其实它包含了简体中文与繁体中文字符,另外还有一种字符 “gb2312”,这种字符仅能存储简体中文字符。
- UTF-8 编码:它是一种全国家通过的一种编码,如果你的网站涉及到多个国家的语言,那么建议你 选择 UTF-8 编码。
GBK 和 UTF8 有什么区别?
UTF8 编码格式很强大,支持所有国家的语言,正是因为它的强大,才会导致它占用的空间大小要比 GBK 大,对于网站打开速度而言,也是有一定影响的。
GBK 编码格式,它的功能少,仅限于中文字符,当然它所占用的空间大小会随着它的功能而减少,打开 网页的速度比较快。
什么时候使用字节流、什么时候使用字符流,二者的区别
先来看一下流的概念:
在程序中所有的数据都是以流的方式进行传输或保存的,程序需要数据的时候要使用输入流读取数据, 而当程序需要将一些数据保存起来的时候,就要使用输出流完成。
InputStream 和 OutputStream,两个是为字节流设计的,主要用来处理字节或二进制对象,
Reader 和 Writer.两个是为字符流(一个字符占两个字节)设计的,主要用来处理字符或字符串.
字符流处理的单元为 2 个字节的 Unicode 字符,操作字符、字符数组或字符串, 字节流处理单元为 1 个字节,操作字节和字节数组。
所以字符流是由 Java 虚拟机将字节转化为 2 个字节的 Unicode 字符为单位的字符而成的, 所以它对多国语言支持性比较好!
如果是音频文件、图片、歌曲,就用字节流好点, 如果是关系到中文(文本)的,用字符流好点
所有文件的储存是都是字节(byte)的储存,在磁盘上保留的并不是文件的字符而是先把字符编码成字 节,再储存这些字节到磁盘。在读取文件(特别是文本文件)时,也是一个字节一个字节地读取以形成 字节序列
字节流可用于任何类型的对象,包括二进制对象,而字符流只能处理字符或者字符串;
字节流提供了处理任何类型的 IO 操作的功能,但它不能直接处理 Unicode 字符,而字符流就可以
字节流是最基本的,所有的 InputStrem 和 OutputStream 的子类都是,主要用在处理二进制数据,它是按 字节来处理的 但实际中很多的数据是文本, 又提出了字符流的概念, 它是按虚拟机的 encode 来处理,也就是要进行字符集的转化
这两个之间通过 InputStreamReader,OutputStreamWriter 来关联, 实际上是通过 byte[]和 String 来关联
在实际开发中出现的汉字问题实际上都是在字符流和字节流之间转化不统一而造成的
Reader 类的 read()方法返回类型为 int :作为整数读取的字符(占两个字节共 16 位),范围在 0 到 65535 之间 (0x00-0xffff),如果已到达流的末尾,则返回 -1
inputStream 的 read()虽然也返回 int,但由于此类是面向字节流的,一个字节占 8 个位,所以返回 0 到 255 范围内的 int 字节值。如果因为已经到达流末尾而没有可用的字节,则返回值 -1。因此对于不能用 0-255 来表示的值就得用字符流来读取!比如说汉字.
递归读取文件夹下的文件,代码怎么实现
/**
* 递归读取文件夹下的 所有文件
* *
@param testFileDir 文件名或目录名
*/
private static void testLoopOutAllFileName(String testFileDir) {
if (testFileDir == null || testFileDir.length() == 0) {
return;
}
File[] testFile = new File(testFileDir).listFiles();
if (testFile == null || testFile.length == 0) {
return;
}
for (File file : testFile) {
if (file.isFile()) {
System.out.println(file.getName());
} else if (file.isDirectory()) {
System.out.println("-------this is a directory, and its files are as follows:-------");
testLoopOutAllFileName(file.getPath());
} else {
System.out.println("文件读入有误!");
}
}
}
SynchronousQueue 实现原理
前言
SynchronousQueue 是一个比较特别的队列,由于在线程池方面有所应用,为了更好的理解线程池的实 现原理,笔者花了些时间学习了一下该队列源码(JDK1.8),此队列源码中充斥着大量的 CAS 语句,理解起 来是有些难度的,为了方便日后回顾,本篇文章会以简洁的图形化方式展示该队列底层的实现原理。
SynchronousQueue 简单使用
经典的生产者-消费者模式,操作流程是这样的:
有多个生产者,可以并发生产产品,把产品置入队列中,如果队列满了,生产者就会阻塞; 有多个消费者,并发从队列中获取产品,如果队列空了,消费者就会阻塞; 如下面的示意图所示:
SynchronousQueue
也是一个队列来的,但它的特别之处在于它内部没有容器,一个生产线程,当它生产产品(即 put 的时 候),如果当前没有人想要消费产品(即当前没有线程执行 take),此生产线程必须阻塞,等待一个消费线 程调用 take 操作,take 操作将会唤醒该生产线程,同时消费线程会获取生产线程的产品(即数据传 递),这样的一个过程称为一次配对过程(当然也可以先 take 后 put,原理是一样的)。 我们用一个简单的代码来验证一下,如下所示:
public class SynchronousQueueDemo {
public static void main(String[] args) throws InterruptedException {
final SynchronousQueue<Integer> queue = new SynchronousQueue<Integer>();
Thread putThread = new Thread(new Runnable() {
public void run() {
System.out.println("put thread start");
try {
queue.put(1);
} catch (InterruptedException e) {
}
System.out.println("put thread end");
}
});
Thread takeThread = new Thread(new Runnable() {
public void run() {
System.out.println("take thread start");
try {
System.out.println("take from putThread: " + queue.take());
} catch (InterruptedException e) {
}
System.out.println("take thread end");
}
});
putThread.start();
Thread.sleep(1000);
takeThread.start();
}
}
put thread start
take thread start
put thread end
take from putThread: 1
take thread end
从结果可以看出,put 线程执行 queue.put(1) 后就被阻塞了,只有 take 线程进行了消费,put 线程才可以 返回。可以认为这是一种线程与线程间一对一传递消息的模型。
SynchronousQueue 实现原理
不像 ArrayBlockingQueue、LinkedBlockingDeque 之类的阻塞队列依赖 AQS 实现并发操作, SynchronousQueue 直接使用 CAS 实现线程的安全访问。由于源码中充斥着大量的 CAS 代码,不易于理 解,所以按照笔者的风格,接下来会使用简单的示例来描述背后的实现模型。 队列的实现策略通常分为公平模式和非公平模式,接下来将分别进行说明。
公平模式下的模型:
公平模式下,底层实现使用的是 TransferQueue 这个内部队列,它有一个 head 和 tail 指针,用于指向当 前正在等待匹配的线程节点。
初始化时,TransferQueue 的状态如下:
接着我们进行一些操作: 1、线程 put1 执行 put(1)操作,由于当前没有配对的消费线程,所以 put1 线程入队列,自旋一小会后睡 眠等待,这时队列状态如下:
2、接着,线程 put2 执行了 put(2)操作,跟前面一样,put2 线程入队列,自旋一小会后睡眠等待,这时队 列状态如下:
3、这时候,来了一个线程 take1,执行了 take 操作,由于 tail 指向 put2 线程,put2 线程跟 take1 线程配对了(一 put 一 take),这时 take1 线程不需要 入队,但是请注意了,这时候,要唤醒的线程并不是 put2,而是 put1。为何?
大家应该知道我们现在讲的是公平策略,所谓公平就是谁先入队了,谁就优先被唤醒,我们的例子明显 是 put1 应该优先被唤醒。至于读者可能会有一个疑问,明明是 take1 线程跟 put2 线程匹配上了,结果是 put1 线程被唤醒消费,怎么确保 take1 线程一定可以和次首节点(head.next)也是匹配的呢?其实大家可 以拿个纸画一画,就会发现真的就是这样的。
公平策略总结下来就是:队尾匹配队头出队。
执行后 put1 线程被唤醒,take1 线程的 take()方法返回了 1(put1 线程的数据),这样就实现了线程间的一 对一通信,这时候内部状态如下:
4、最后,再来一个线程 take2,执行 take 操作,这时候只有 put2 线程在等候,而且两个线程匹配上了, 线程 put2 被唤醒, take2 线程 take 操作返回了 2(线程 put2 的数据),这时候队列又回到了起点,如下所示:
以上便是公平模式下,SynchronousQueue 的实现模型。总结下来就是:队尾匹配队头出队,先进先 出,体现公平原则。
非公平模式下的模型:
我们还是使用跟公平模式下一样的操作流程,对比两种策略下有何不同。非公平模式底层的实现使用的 是 TransferStack, 一个栈,实现中用 head 指针指向栈顶,接着我们看看它的实现模型:
1、线程 put1 执行 put(1)操作,由于当前没有配对的消费线程,所以 put1 线程入栈,自旋一小会后睡眠 等待,这时栈状态如下:
2、接着,线程 put2 再次执行了 put(2)操作,跟前面一样,put2 线程入栈,自旋一小会后睡眠等待,这时 栈状态如下:
3、这时候,来了一个线程 take1,执行了 take 操作,这时候发现栈顶为 put2 线程,匹配成功,但是实现 会先把 take1 线程入栈,然后 take1 线程循环执行匹配 put2 线程逻辑,一旦发现没有并发冲突,就会把栈 顶指针直接指向 put1 线程
4、最后,再来一个线程 take2,执行 take 操作,这跟步骤 3 的逻辑基本是一致的,take2 线程入栈,然后 在循环中匹配 put1 线程,最终全部匹配完毕,栈变为空,恢复初始状态,如下图所示:
可以从上面流程看出,虽然 put1 线程先入栈了,但是却是后匹配,这就是非公平的由来。
总结
SynchronousQueue 由于其独有的线程一一配对通信机制,在大部分平常开发中,可能都不太会用到, 但线程池技术中会有所使用,由于内部没有使用 AQS,而是直接使用 CAS,所以代码理解起来会比较困 难,但这并不妨碍我们理解底层的实现模型,在理解了模型的基础上,有兴趣的话再查阅源码,就会有 方向感,看起来也会比较容易,希望本文有所借鉴意义。