面向对象
面向对象与面向过程
什么是面向过程?
自顶而下的编程模式。把问题分解成一个个步骤,每个步骤用函数实现,依次调用即可。
什么是面向对象?
将事务高度抽象化的编程模式。将问题分解成一个个步骤,对每个步骤进行相应的抽象,形成对象,通过不同对象之间的调用,组合解决问题。
优劣对比
- 面向对象:占用资源相对高,速度相对慢
面向过程:占用资源相对低,速度相对快
面向对象的三大基本特征和五大基本原则
面向对象的三大基本特征
封装(Encapsulation)
所谓封装,就是把客观事物封装成抽象的类,并且类可以把自己的属性和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。
封装是面向对象的特征之一,是对象和类概念的主要特征。一个类就是封装了数据以及操作这些数据的代码的逻辑实体。在一个对象内部,某些代码或某些数据可以是私有的,不被外界访问。通过这种方式,对象对内部类数据提供了不同级别的保护,以防止程序中无关的部分意外的改变或错误的使用了对象的私有部分。
继承(Inheritance)
继承是指这样一种能力:可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。
通过继承创建的新类称为“子类”或“派生类”,被继承的类称为“基类”,“父类”或“超类”。继承的过程,就是从一般到特殊的过程。
继承概念的实现方式:实现继承和接口继承。实现继承是指直接使用基类的属性和方法而无需额外的编码能力;接口继承是指仅使用属性和方法名称,但子类必须提供实现的能力。
多态(Polymorphism)
多态是指一个类实例的相同方法在不同情形有不同表现得形式。多态机制使具有不同内部结构的对象可以共享相同的外部接口。这意味着,虽然针对不同对象的具体操作不同,但通过一个公共类,它们可以以相同的方式予以调用。
最常见的多态就是将子类传入父类参数中,运行时调用父类方法时通过传入的子类决定具体的内部结构或行为。
面向对象的五大基本原则
单一职责原则(Single-Responsibility Principle)
开放封闭原则(Open-Closed Principle)
Liskov 替换原则(Liskov-Substitution Principle)
依赖倒置原则(Dependecy-Inversion Principle)
接口隔离原则(Interface-Segregation Principle)
Java中的封装、继承、多态
什么是多态
同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。多态是一种运行期的状态。
多态的必要条件
为了实现运行期的多态,或者说是动态绑定,需要满足三个条件:
- 有类继承或者接口实现
- 子类重写父类方法
- 父类的引用指向子类的对象
1 | public class Parent{ |
这样,就实现了多态,同样是Parent类的实例,p.call调用的是Son类的实现,p1.call调用的是Daughter的实现。例如Spring中IOC出来的对象,你在使用的可以不用关心他是谁,根据具体情况而定。
上面提到的那种动态绑定是动态多态,因为只有在运行期才能知道正真调用的是哪个类的方法。还有一种静态多态,一般认为Java中的函数重载是一种静态多态,因为它需要在编译期决定具体调用哪个方法。
方法重写与重载
重载(Overloading)和重写(Overriding)是Java中两个比较重要的概念。
重载
函数或方法有相同的名称,但是参数列表不同的情形,这样的同名不同参数的函数或者方法之间,互相称之为重载函数或者方法。
重写
重写指的是在Java的子类与父类中有两个名称、参数列表都相同的方法的情况。由于他们具有相同的方法签名,所以子类中的新方法将覆盖父类中原有的方法。
重写与重载的区别
- 重载是一个编译期概念,重写是一个运行期间的概念
- 重载遵循所谓“编译期绑定”,即在编译时根据参数量的类型判断应该调用哪个方法。
- 重写遵循所谓“运行期绑定”,即在运行的时候根据引用变量所指向的实际对象的类型来调用方法。
1 | class Dog{ |
上面的例子中,dog对象被定义为Dog类型。在编译期,编译器会检查Dog类中是否有可访问的bark()方法,只要其中包含bark()方法,那么就可以编译通过。在运行期,Hound对象被new出来,并赋值给dog变量,这时,JVM是明确的知道dog变量指向的其实是Hound对象的引用。所以,当dog调用bark()方法的时候,就会调用Hound类中定义的bark()方法。这就是所谓的动态多态性。
重写的条件
- 参数列表必须完全与被重写方法的相同
- 返回类型必须与被重写方法的返回类型相同
- 访问级别的限制性一定不能比被重写方法的强
- 访问级别的限制性可以比被重写的方法的弱
- 重写方法一定不能抛出新的检查异常或比被重写的方法声明的检查异常更广泛的检查异常
- 重写的方法能够抛出更少或更有限的异常
- 不能重写被标示为final的方法
- 如果不能继承一个方法,则不能重写这个方法
1 | class Dog{ |
上面的代码中,定义了两个bark方法,一个是没有参数的bark方法,另外一个是包含一个int类型参数的bark方法。在编译期,编译期可以根据方法签名(方法名和参数情况)确定哪个方法被调用。
重载的条件
- 被重载的方法必须改变参数列表
- 被重载的方法可以改变返回类型
- 被重载的方法可以改变访问修饰符
- 被重载的方法可以声明新的或更广的检查异常
- 方法能够在同一类中或者一个子类中被重载
Java的继承与实现
面向对象有三个特性:封装、继承、多态
其中继承和实现都体现了传递性。
继承:如果多个类的某个部分的功能相同,那么可以抽象出一个类出来,把他们的相同部分放到父类里,让他们都继承这个类。
实现:如果多个类处理的目标是一样的,但是处理的方法方式不同,那么就定义一个接口,也就是一个标准,让他们的实现这个接口,各自实现自己具体的处理方法来处理那个目标。
继承的根本原因是因为要复用,而实现的根本原因是需要定义一个标准。
在Java中,继承使用extends关键字实现,而实现通过implements关键字。在Java中支持一个类同时实现多个接口,但不支持同时继承多个类。
在接口中只能定义全局常量(static final)和无实现的方法(Java 8以后可以有default方法),然而在继承中可以定义属性方法,变量,常量等。
Java的继承与组合
面向对象的复用技术
复用性是面向对象技术带来的很棒的潜在的好处之一。如果运用的好的话可以帮助我们节省很多开发时间,提升开发效率。但是,如果被滥用那么就很可能产生很多难以维护的代码。作为一门面向对象开发的语言,代码复用是Java引人注意的功能之一。Java代码的复用有继承,组合以及代理三种具体的表现形式。
继承
继承(Inheritance)是一种联结类与类的层次模型。指的是一个类(子类、子接口)继承另外一个类(父类、父接口)的功能,并可以增加它自己的新功能的能力,继承是类与类或者接口与接口之间最常见的关系;继承是一种is-a关系。
组合
组合(Composition)体现的是整体与部分、拥有的关系,即has-a的关系。
继承和组合的区别与联系
在继承结构中,父类的内部细节对于子类是可见的。所以我们通常也可以说通过继承的代码复用一种白盒式代码复用。(如果基类的实现发生改变,那么派生类的实现也将随之改变。这样就导致了子类行为的不可预知性。)
组合是通过对现有的对象进行拼装产生新的,更复杂的功能。因为在对象之间,各自的内部细节是不可见的,所以我们也可以说这种方式的代码复用是黑盒式代码复用。(因为组合中一般都有定义一个类型,所以在编译期根本不知道具体会调用哪个实现类的方法)
继承,在写代码的时候就要指名具体继承哪个类,所以,在编译期就确定了关系。(从基类继承来的实现是无法在运行期动态改变的,因此降低了应用的灵活性。)
组合,在写代码的时候可以采用面向接口编程。所以,类的组合关系一般在运行期确定。
优缺点对比
组合关系 | 继承关系 |
---|---|
优点:不破坏封装,整体类与局部类之间松耦合,彼此相对独立 | 缺点:破坏封装,子类与父类之间紧密耦合,子类依赖于父类的实现,子类缺乏独立性 |
优点:具有较好的可扩展性 | 缺点:支持扩展,但是往往以增加系统结构的复杂度为代价 |
优点:支持动态组合。在运行时,整体对象可以选择不同类型的局部对象 | 缺点:不支持动态继承。在运行时,子类无法选择不同的父类 |
优点:整体类可以对局部类进行包装,封装局部类的接口,提供新的接口 | 缺点:子类不能改变父类的接口 |
缺点:整体类不能自动获得和局部类同样的接口 | 优点:子类能自动继承父类的接口 |
缺点:创建整体类的对象时,需要创建所有局部类的对象 | 优点:创建子类的对象时,无需创建父类的对象 |
如何选择
建议在同样可行的情况下,优点使用组合而不是继承。因为组合更安全,更简单,更灵活,更高效。注意,并不是说继承就一点用都没有,在一些场景还是需要使用继承的,或者是更适合使用继承。
继承要慎用,其使用场合仅限于你确信使用该技术有效的情况。一个判断方法是,问一问自己是否需要从新类向基类进行向上转型。如果是必须的,则继承是必要的。反之则应该好好考虑是否需要继承。
只有当子类正真是超类的子类型时,才适合用继承。换句话说,对于两个类A和B,只有当两者之间确实存在is-a关系的时候,类B才应该继承类A。
构造函数与默认构造函数
构造函数是一种特殊的方法。主要用来在创建对象时初始化对象,即为对象成员变量赋初始值,总与new运算符一起使用在创建对象的语句中。特别的一个类可以有多个构造函数,可根据其参数个数的不同或参数类型的不同来区分它们即构造函数的重载。
构造函数跟一般的实例方法十分相似;但是与其他方法不同,构造器没有返回类型,不会被继承,且可以有范围修饰符。构造器的函数名称必须和它所属的类的名称相同。他承担着初始化对象数据成员的任务。
如果在编写一个可实例化的类时没有专门编写构造函数,多数编程语言会自动生成缺省构造器。默认构造函数一般会把成员变量的初始化为默认值,如int->0,Integer->null。
类变量、成员变量和局部变量
Java中共有三种变量,分别是类变量、成员变量、局部变量。它们分别存放在JVM的方法区、堆内存、栈内存中。
1 | public class Variables{ |
上面定义的变量中,变量a就是类变量,变量b就是成员变量,而变量c和d是局部变量。
成员变量和方法作用域
对于成员变量和方法的作用域,public,protected,private以及不写之间的区别:
- public:表明该成员变量或者方法是对所有类或者对象都是可见的,所有类或者对象都可以直接访问。
- private:表明该成员变量或者方法是私有的,只有当前类对其具有访问权限,除此之外其它类或者对象都没有访问权限,子类也没有访问权限。
- protected:表明成员变量或者方法对类自身,与同一个包中的其它类可以见,其他包下的类不可访问,除非是它的子类。
- default:表明该成员变量或者方法只有和其位于同一个包的内可见,其他包内的类不能访问,即便是它的子类。
什么是平台无关性
Java如何实现的平台无关性
什么是平台无关性
平台无关性就是一种语言在计算机上的运行不受平台约束,一次编译,到处执行(Write Once ,Run Anywhere)。也就是说,用Java创建的可执行二进制程序,能够不加改变的运行于多个平台。
平台无关性好处
因为其平台无关性,所以Java程序可以在各种各样的设备上,尤其是一些嵌入式设备,如打印机、扫描仪、传真机等。随着5G时代的来临,也会有更多的终端接入网络,相信平台无关性的Java也能做出一些贡献。
对于Java开发者来说,Java减少了开发和部署到多个平台的成本和时间。真正的做到一次编译,到处运行。
平台无关性的实现
对于Java的平台无关性的支持,就像对安全性和网络移动性的支持一样,是分布在整个Java体系结构中的。其中扮演着重要的角色的有Java语言规范、Class文件、Java虚拟机(JVM)等。
编译原理基础
在计算机世界中,计算机只认识0和1,所以真正被计算机执行的其实是由0和1组成的二进制文件。但是,我们日常开发使用的C、C++、Java、Python等都属于高级语言,而非二进制语言。所以,想要让计算机认识我们写出来的Java代码,那就需要把他“翻译”成由0和1组成的二进制文件。这个过程就叫做编译。负责这一过程的处理的工具叫做编译器。
在Java平台上,想要把Java文件编译成二进制文件,需要经过两步编译,前端编译和后端编译:
- 前端编译:主要指与源语言有关但与目标机无关的部分。Java中,我们熟知的javac的编译就是前端编译。除此之外,我们使用的很低IDE,如eclipese,idea等,都内置了前端编译器。主要功能就是把.java代码转换成.class文件。
- 后端编译:主要是将中间代码再翻译成机器语言。Java中,这一步骤就是Java虚拟机来执行的。
Java虚拟机
所谓平台无关性,就是说要能够做到可以在多个平台上都能无缝对接。但是,对于不同的平台,硬件和操作系统肯定都是不一样的。
对于不同硬件和操作系统,最主要的区别就是指令不同。比如同样执行a+b,A操作系统对应的二进制指令可能是10001000,而B操作系统对应的指令可能是11101110。那么,想要做到跨平台,最重要的就是可以根据对应的硬件和操作系统生成对应的二进制指令。
而这一工作,主要由我们的Java虚拟机完成。虽然Java语言是平台无关的,但JVM却是平台有关的,不同的操作系统上面要安装对应的JVM。
有了Java虚拟机,想要执行a+b操作,A操作系统上面的虚拟机就会把指令翻译成10001000,B操作系统上面的虚拟机就会把指令翻译成11101110。所以,Java之所以可以做到跨平台,是因为Java虚拟机充当了桥梁。它扮演了运行时Java程序与其下的硬件和操作系统之间的缓冲角色。
字节码
各种不同的平台的虚拟机都使用了统一的程序存储格式——字节码(ByteCode)是构成平台无关性的另外一个基石。Java虚拟机只与由字节码组成的Class文件进行交互。我们说的Java语言可以Write Once ,Run Anywhere。这里的Write其实指的就是生成Class文件的过程。因为Java Class文件可以在任何平台创建,也可以被任何平台的Java虚拟机装载并执行,所以才有了Java的平台无关性。
Java语言规范
已经有了统一的Class文件,以及可以在不同平台上将Class文件翻译成对应的二进制文件的Java虚拟机,Java就可以实现跨平台了吗?其实并不是的,Java语言在跨平台方面也是做了一些努力的,这些努力被定义在Java语言规范中。
比如,Java中基本数据类型的值域和行为都是由其自己定义的。而C/C++中,基本数据类型由它的占位宽度决定的,占位宽度则是由所在平台决定的。所以,在不同的平台中,对于同一个C++程序的编译结果会出现不同的行为。举一个简单的例子,对于int类型,在Java中,int占4个字节,这是固定的。但是在C++中却不是固定的了。在16位计算机上,int类型的长度可能为两个字节;在32位的计算机上,可能为4字节;当64位计算机流行起来后,int类型的长度可能会达到8字节。
通过保证基本数据类型在所有平台的一致性,Java语言为平台无关性提供了强有力的支持。
小结
对于Java的平台无关性的支持是分布在整个Java体系结构中的,其中扮演着重要角色的有Java语言规范、Class文件、Java虚拟机等。
Java语言规范:通过规定Java语言中基本数据类型的取值范围和行为
Class文件:所有Java文件都要编译成统一的Class文件
Java虚拟机:通过Java虚拟机将Class文件转换成对应平台的二进制文件等
Java的平台无关性是建立在Java虚拟机的平台有关性基础之上的,是因为Java虚拟机屏蔽了底层操作系统和硬件的差异。
语言无关性
Java的无关性不仅仅体现在平台无关性上面,Java还具有语言无关性。
JVM其实并不是和Java文件交互的,而是和Class文件,也就是说,其实JVM运行的时候,并不依赖于Java语言。
时至今日,商业机构金额开源机构已经在Java语言之外发展出了一大批可以在JVM上运行的语言了,如Groovy、Scala、Jython等。之所以可以支持,就是因为这些语言也可以被编译成字节码。而虚拟机并不关心字节码是由哪种语言编写出的。
JVM还支持哪些语言
目前Java虚拟机已经可以支持很多除Java语言以外的语言了,如Kotlin、Groovy、JRuby、Jython、Scala等。之所以可以支持,就是因为这些语言也可以被编译成字节码。
Java中的值传递
值传递、引用传递
实参与形参
在Java中定义方法的时候是可以定义参数。比如Java中的main方法,public static void main(String[] args),这里args就是参数。参数在程序语言中分为形式参数和实际参数。
- 形式参数:是定义在函数名和函数体的时候使用的参数,目的是用来接收调用该函数时传入的参数。
- 实际参数:在调用有参函数时,主调函数和被调函数之间有数据传递关系。在主调函数中调用一个函数时,函数名后面括号中的参数称为“实际参数”。
1 | public static void main(String[] args){ |
实际参数是调用有参方法的时候真正传递的内容,而形式参数是用于接收实参内容的参数。
值传递与引用传递
当我们调用一个有参函数的时候,会把实际参数传递给形式参数。但是在程序语言中,这个传递过程中传递的两种情况,即值传递和引用传递。
值传递(pass by value):在调用函数时将实际参数赋值一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。
引用传递(pass by reference):在调用函数时将实际参数地址直接传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。
所以值传递会创建副本,函数中无法改变原始对象;引用传递不创建副本所以函数中可以改变原始对象。
求值策略
我们说当进行方法调用的时候,需要把实际参数传递给形式参数,那么传递的过程中到底传递的是什么东西呢?这其实是程序设计中求值策略(Evaluation strategies)的概念。
在计算机科学中,求值策略是确定编程语言中表达式的求值的一组规则。求值策略定义何时和以何种顺序求值给函数的实际参数、什么时候把它们代换入函数、和代换以何种形式发生。
求值策略分为两大基本类,基于如何处理给函数的实际参数,分为严格的和非严格的。
严格求值
在“严格求值”中,函数调用过程中,给函数的实际参数总是在应用这个函数之前求值。多数现存编程语言对函数都使用严格求值。
在严格求值中有几个关键的求值策略是我们比较关心的,那就是传值调用(Call by value)、传引用调用(Call by reference)以及传共享对象调用(Call by sharing)。
传值调用(值传递):在传值调用中,实际参数先被求值,然后其值通过复制,被传递给被调用函数的形式参数。因为形式参数拿到的只是一个“局部拷贝”,所以如果在被调函数中改变了形式参数的值,并不会改变实际参数的值。
传引用调用(引用传递):在传引用调用中,传递给函数的是它的实际参数的隐式引用而不是实参的拷贝。因为传递的是引用,所以,如果在被调函数中改变了形式参数的值,改变对于调用者来说是可见的。
传共享对象调用(共享对象传递):传共享对象调用中,先获取到实际参数的地址,然后将其复制后,并把该地址的拷贝传递给被调函数的形式参数。因为参数的地址都指向同一个对象,所以我们也称之为“传共享对象”,所以,如果在被调函数中改变了形式参数的值,调用者是可以看到这种变化的。
其中传共享对象调用和传值调用的过程几乎是一样的,都是进行“求值”、“拷贝”、“传递”。但是,传共享对象调用和传引用调用的结果又是一样的,都是在被调函数中如果改变参数的内容,那么这种改变也会对调用者有影响。
传值调用和传引用调用的主要区别:传值调用是指在调用函数时将实际参数复制一份传递到函数中,传引用调用是指在调用函数时将实际参数的引用直接传递到函数中。所以,连着最主要的区别就是是直接传递的,还是传递的是一个副本。
Java的求值策略
1 | public static void main(String[] args){ |
可以看到,对象类型在被传递到pass方法后,在方法内容改变了其内容,最终调用main方法中的对象也变了。所以,很多人说,这和引用传递的现象是一样的,就是在方法内改变参数的值,会影响到调用方。但是,其实这是走进了一个误区。
Java中的对象传递
原始参数通过值传递给方法。这意味着对参数值的任何更改都只存在于方法的范围内。当方法返回时,参数将消失,对它们的任何更改都将丢失。也就是说,引用数据类型参数也按值传递给方法,这意味着,当方法返回时,传入的引用仍然引用与以前相同的对象。但是,如果对象字段具有适合的访问级别,则可以在方法中更改这些字段的值。
其实Java中使用的求值策略就是传共享对象调用,也就是说,Java会将对象的地址的拷贝传递给被调函数的形式参数。只不过“传共享对象调用”这个词并不常用,传共享对象调用其实是传值调用的一个特例。
值传递和共享对象传递的现象冲突吗?
既然贡献对象传递是值传递的一个特例,那么它们的现象是完全不同的呢?难道值传递过程中,如果在被调方法中改变了值,也有可能会对调用者有影响吗?那到底什么时候会影响什么时候不会影响呢?
其实是不冲突的,之所以会有这种疑惑,是因为对于到底是什么是“改变值”有误解。在参数传递的过程中,实际参数的地址被拷贝给了形参。这个过程其实就是值传递,只不过传递的值的内容是对象的应用。
那为什么我们改变了user中的属性的值,却对原来的user产生了影响呢?Java对象的传递,是通过复制的方式把引用关系传递了,如果我们没有改引用关系,而是找到引用的地址,把里面的内容改了,是会对调用者有影响的,因为大家指向的是同一个共享对象。
1 | public void pass(User user){ |
所以,Java中的对象传递,如果是修改引用,是不会对原来的对象有任何影响的,但是如果直接修改共享对象的属性的值,是会对原来的对象有影响的。
总结
编程语言中需要进行方法间的参数传递,这个传递的策略就叫做求值策略。
在程序设计中,求值策略有很多种,比较常见的就是值传递和引用传递。还有一种值传递的特例——共享对象传递。
值传递和引用传递最大的区别是传递的过程中没有复制出一个副本来,如果是传递副本,那就是值传递,否则就是引用传递。
在Java中,其实是通过值传递实现的参数传递,只不过对于Java对象的传递,传递的内容是对象的引用。我们可以总结说,Java中的求值策略是贡献对象传递,这是完全正确的。
Java语言基础
基本数据类型
8种基本数据类型
- 字符型:char
- 布尔型:boolean
- 数值型:
- 整型:byte、short、int、long
- 浮点型:float、double
整型中byte、short、int、long的取值范围
Java中的整型主要包含byte、short、int、long这四种,表示的数字范围也是从小到大的,之所以表示范围不同主要和它们存储数据时所占的字节数有关。1字节=8位(bit),Java中的整型属于有符号数。
8bit可以表示的数字:最小值:10000000(-128)($-2^7$),最大值:01111111(127)($2^7-1$)
byte:byte用1个字节来存储,范围为-128~127,在变量初始化的时候,byte类型的默认值为0。
short:short用2个字节存储,范围为-32768~32767,在变量初始化的时候,short类型的默认值为0,一般情况下,因为Java本身转型的原因,可以直接写为0。
int:int用4个字节存储,范围为:-2147483648~2147483647,在变量初始化的时候,int类型的默认值为0。
long:long用8个字节存储,范围为(-$2^{63}$)~($2^{63}-1$),在变量初始化的时候,long类型的默认值为0L,也可以直接写为0。
上面说过了,整型中,每个类型都有一定的表示范围,但是,在程序中有些计算会导致超出表示范围,即溢出。
1 | int i = Integer.MAX_VALUE; |
这就是发生了溢出,溢出的时候并不会抛出异常,也没有任何提示。所以,在程序中,使用同类型的数据进行运算的时候,一定要注意数据溢出的问题。
什么是浮点型?
在计算机科学中,浮点是一种对于实数的近似值数值表现法,由一个有效数字加上幂数来表示,通常是乘以某个基数的整数次指数得到。以这种表示法表示的数值,称为浮点数(floating-point number)
计算机使用浮点数运算的主因,在于电脑使用二进制位的运算。例如4的二进制位100,2的二进制位010,在二进制中,相当于退一位数(100->010)。
1的二进制是01,那么0.5的二进制标为(0.1),以此类推,0.25的二进制表示位(0.01),所以,并不是所有的十进制小数都能准确的用二进制表示出来,如0.1,因此只能使用近似值的方式表达。
十进制的小数在计算机中是由一个整数或定点数乘以某个基数的整数次幂得到的,这种表示类似于基数位10的科学计数法。
一个浮点数a由两个数m和e来表示:$a = m\times be$。在任意一个这样的系统中,我们选择一个基数b(记数系统的基)和精度p(即使用多少位来存储)。m(即尾数)是形如$\pm d.ddd.ddd$的p位数(每一位是一个介于0到b-1之间的整数,包括0和b-1)。如果m的第一位是非0整数,m称作正规化的。有一些描述使用一个单独的符号位(s代表+或者-)来表示正负,这样m必须是正的。e是指数。
位(bit)是衡量浮点数所需要存储空间的单位,通常为32或者64位,分别被叫做单精度和双精度。
什么是单精度和双精度
单精度浮点数在计算机存储器中占用4个字节(32bits),利用“浮点”(浮动小数点)的方法,可以表示一个范围很大的数值。
比其单精度浮点数,双精度浮点数(double)使用64位(8字节)来存储一个浮点数。
为什么不能用浮点型表示金额
由于计算机中保存的小数其实是十进制的小数的近似值,并不是准确值,所以,千万不要在代码中使用浮点数来表示金额等重要的指标。
建议使用BigDecimal或者Long(单位为分)来表示金额。
Java中关键字
transient
在关于Java的集合类的学习中,我们发现ArrayList类和Vector类都是使用数组实现的,但是在定义数组elementDate这个属性时稍有不同,那就是ArrayList使用了transient关键字。
1 | private transient Object[] elementDate; |
transient是Java语言的关键字,变量修饰符,如果用transient声明一个实例变量,当对象存储时,它的值不需要维持。这里的对象存储是指,Java的serialization提供的一种持久化对象实例机制。当一个对象被序列化的时候,transient型变量的值不包括在序列化的表示中,然而非transient型的变量是被包括进去的。使用情况是:当持久化对象时,可能有个特殊的对象数据成员,我们不想用serialization机制来保存它。为了在一个特定对象的一个域上关闭serialization,可以在这个域前加上关键字transient。
简单点说,就是被transient修饰的成员变量,在序列化的时候其值会被忽略,在被反序列化后,transient变量的值被设为初始值,如int型的是0,对象型的是null。
instanceof
instanceof是Java的一个二元操作符,类似于==,>,<等操作符。instanceof是Java的保留关键字。它的作用是测试它的左边对象是否是它右边的类的实例,返回boolean的数据类型。
1 | public static void displayObjectClass(Object o){ |
volatile
Java语言为了解决并发编程中存在的原子性、可见性和有序性问题,提供了一系列和并发处理相关的关键字,比如synchronized、volatile、final、concurren包等。
volatile通常被比喻为“轻量级的synchronized”,也是Java并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了。
1 | public class Singleton{ |
这是一个比较典型的使用双重锁校验的形式实现单例的,其中使用volatile关键字修饰符可能被多个线程同时访问到的singleton。
volatile原理
为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。但是,对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。
缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作,会强制重新从系统内存里把数据读到处理器缓存里。
如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了volatile在并发编程中,其值在多个缓存中是可见的。
volatile与可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己得工作内存和主内存之间进行数据同步进行。所以,就可能出现线程1改变了某个变量的值,但线程2不可见的情况。
Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。
volatile与有序性
有序性即程序执行的顺序按照代码的先后顺序执行。
由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save有可能被优化成load->save->add。这就是可能存在的有序性问题。
而volatile除了可以保证数据的可见性之外,还有一个强大的功能,那就是它可以禁止指令重排优化等。
普通的变量仅仅会保证在该方法的执行过程中依赖的赋值结果的地方都能获得正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。volatile可以禁止指令重排,这就保证了代码会严格按照代码的先后顺序执行。这就保证了有序性。被volatile修饰的变量的操作,会严格的按照代码顺序执行,load->add->save的执行顺序就是:load、add、save。
volatile与原子性
原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。volatile是不能保证原子性的。
在以下两个场景中可以使用volatile来代替synchronized:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值
- 变量不需要与其他状态变量共同参与不变约束
1 | public class Test{ |
以上的代码,就是创建10个线程,然后分别执行了1000次i++操作。正常情况下,程序的输出结果应该是10000,但是,多次执行后的结果都小于10000.这其实就是volatile无法满足原子性的原因。
总结
synchronized可以保证原子性、有序性和可见性。而volatile却只能保证有序性和可见性。
synchronized
synchronized关键字在需要原子性、可见性和有序性这三种特性的时候都可以作为其中一种解决方案,看起来是“万能”的。的确,大部分并发控制操作都能使用synchronized来完成的。
synchronized的用法
synchronized是Java提供一个并发控制的关键字。主要有两种用法,分别是同步方法和同步代码块。也就是说,synchronized既可以修饰方法也可以修饰代码块。
1 | public class SynchronizedDemo{ |
被synchronized修饰的代码块及方法,在同一时间,只能被单个线程访问。
synchronized的实现原理
synchronized是Java中用于解决并发情况下数据同步访问的一个很重要的关键字。当我们要保证一个共享资源在同一个时间只会被一个线程访问到时,我们可以在代码中使用synchronized关键字对类或者对象加锁。
通过反编译后代码可以看出:对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。对于同步块代码,JVM采用monitorenter、monitorexit两个指令来实现同步。
方法级的同步是隐式的。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会自动释放。
同步代码块使用monitorenter、monitorexit两个指令实现。可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。每个对象维护着一个记录被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁后,该计数器自增变为1,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁的时候,计数器再自减。当计数器为0的时候,锁将被释放,其他线程便可以获得锁。
无论是ACC_SYNCHRONIZED还是monitorenter、monitorexit都是基于Monitor实现的,在Java虚拟机中,Monitor是基于C++实现的,由ObjectMonitor实现。ObjectMonitor类中提供了几个方法,如enter、exit、wait、notify、notifyAll等。sychronized加锁的时候,会调用objectMonitor的enter方法,解锁的时候会调用exit方法。
synchronized与原子性
原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。volatile是不能保证原子性的。
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。通过monitorenter和monitorexit指令,可以保证被synchronized修饰的代码在同一时间被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在Java中可以使用synchronized来保证方法和代码块的操作是原子性的。
线程1在执行monitorenter指令的时候,会对Monitor进行加锁,加锁后其他线程无法获得锁,出非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是,他并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获得到,还是会继续执行代码。直到所有代码执行完这就保证了原子性。
synchronized与可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己得工作内存和主内存之间进行数据同步进行。所以,就可能出现线程1改变了某个变量的值,但线程2不可见的情况。
被synchronized修饰的代码,在开始执行时会加锁,执行完毕后会进行解锁。而为了保证可见性,有一条规则是这样的:对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的值。
所以,synchronized关键字锁住的对象,其值是具有可见性的。
synchronized与有序性
有序性即程序执行的顺序按照代码的先后顺序执行。
由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save有可能被优化成load->save->add。这就是可能存在的有序性问题。
这里需要注意的是,synchronized是无法禁止指令重排和处理器优化的。也就是说,synchronized无法避免上述提到的问题。那么,为什么还说synchronized也提供了有序性保证呢?Java程序中天然的有序性可以总结为:如果在本线程内观察,所有操作都是天然有序的。如果是在一线程观察另外一个线程,所有操作都是无序的。
as-if-serial语义的意思指:不管怎么重排序,单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。
在单线程中,指令重排序是有一定的限制的,而只要编译器和处理器都遵守这个语义,那么就可以认为单线程程序是按照顺序执行的。当然,实际上还是有重排的,只不过我们无需关心这种重排的干扰。
所以,由于synchronized修饰的代码,同一时间只能被同一线程访问。那么也就是单线程执行的。所以,可以保证其有序性。
synchronized与锁优化
synchronized其实是借助Monitor实现的,在加锁时会调用objectMonitor的enter方法,解锁的时候会调用exit方法。事实上,只有JDK1.6之前,synchronized的实现才会直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。
在JDK1.6中出现对锁进行了很多优化,进而出现轻量级锁、偏向锁、锁消除、适应性自旋锁,锁粗化,这些操作都是为了在线程之间更高效的共享数据,解决竞争问题。
final
final是Java中的一个关键字,它表示的是“这部分是无法修改的”。使用final可以定义:变量、方法、类。
final变量
如果将变量设置为final,则不能更改final变量的值。
1 | class Test{ |
一旦final变量被定义之后,是无法进行修改的。
final方法
如果任何方法声明为final,则不能覆盖它。
1 | class Parent{ |
当我们定义以上类的子类的时候,无法覆盖其name方法,会编译失败。
final类
如果把任何一个类声明为final,则不能继承它。
1 | final class Parent{ |
以上类不能被继承!
static
static表示“静态”的意思,用来修饰成员变量和成员方法,也可以形成静态static代码块。
静态变量
我们用static表示变量的级别,一个类中的静态变量,不属于类的对象或者实例。因为静态变量与所有的对象实例共享,因为它们不具有线程安全性。
通常,静态变量常用final关键字来修饰,表示通用资源或可以被所有对象所使用。如果静态变量未被私有化,可以用“类名.变量名”的方式来使用。
静态方法
与静态变量一样,静态方法是属于类而不是实例。
一个静态方法只能使用静态变量和调用静态方法。通常静态方法通常用于想给其它的类使用而不需要创建实例。
Java的包装类和实用类包含许多静态方法。main()方法就是Java程序的入口点,是静态方法。
1 | //JDK6 |
从Java8以上版本开始也可以有接口类型的静态方法了。
静态代码块
Java的静态块是一组指令在类装载的时候在内存中由Java ClassLoader执行。静态块常用于初始化类的静态变量。大多数时候还用于在类装载时候创建静态资源。Java不允许在静态块中使用非静态变量。一个类中可以有多个静态块,尽管这似乎没有什么用。静态块只在类装载入内存时,执行一次。
静态类
Java可以嵌套使用静态类,但是静态类不能用于嵌套的顶层。静态嵌套类的使用与其他顶层类一样,嵌套只是为了便于项目打包。
const
const是Java预留关键字,用于后期扩展用,用法跟final相似,不常用。
String
字符串的不可变性
定义一个字符串
1 | String s = "abcd"; |
s中保存了string对象的引用。
使用变量来赋值变量
1 | String s2 = s; |
s2保存了相同的引用值,因为他们代表同一个对象。
字符串连接
1 | s = s.concat("ef"); |
s中保存的是一个重新创建出来的string对象的引用。
总结
一旦一个String对象在内存中被创建出来,他就无法被修改。特别要注意的是,String类的所有方法都没有改变字符串本身的值,都是返回了一个新的对象。
如果你需要一个可修改的字符串,应该使用StringBuffer或者StringBuilder。否则会有大量时间浪费在垃圾回收上,因为每次试图修改都有新的String对象被创建出来。
JDK6和JDK7中substring的原理及区别
substring(int beginIndex, int endIndex)方法在不同版本的JDK中的实现是不同的。了解它们的区别可以帮助你更好的使用它。为了方便,记使用substring()代表substring(int beginIndex, int endIndex)方法。
substring()的作用
substring(int beginIndex, int endIndex)方法截取字符串并返回其[beginIndex, endIndex-1]范围内的内容。
1 | String x = "abcdef"; |
调用substring()时发生了什么
x是不可变的,当使用x.substring(1,3)对x赋值的时候,他会指向一个全新的字符串。然而,在jdk6和jdk7中调用substring时发生的事情并不一样。
JDK6中的substring
String是通过字符数组实现的。在JDK6中,String类包含三个成员变量:char value[ ],int offset, int count。他们分别用来存储真正的字符数组,数组的第一个位置的索引以及字符串中包含的字符个数。
当调用substring方法的时候,会创建一个新的string对象,但这个string的值仍然指向堆中的同一个字符数组。这两个对象中只有count和offset的值是不同的。
1 | String(int offset, int count, char value[]){ |
JDK6中substring导致的问题
如果有一个很长的字符串,但是当你使用substring进行切割的时候你只需要很短的一段。这可能导致性能问题,你只需要一小段的字符序列,但你引用了整个字符串(因为这个非常长的字符串一直在被引用,所以无法被回收,就可能导致内存泄露)。在JDK6中,一般用以下方式来解决该问题,原理其实就是生成了一个新的字符串并引用它。
1 | x = x.substring(x,y) + ""; |
内存泄露:在计算机科学中,内存泄露指由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄露并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成内存的浪费。
JDK7中的substring
上面的问题,在JDK7中得到解决,在JDK7中substring方法会在堆内存中创建一个新的数组。
1 | //JDK7 |
在JDK7中substring方法,其实用new String创建了一个新的字符串,避免对老字符串的引用。从而解决了内存泄露问题。
所以,如果你的生产环境中的JDK版本小于1.7,当你使用String的substring方法时一定要注意,避免内存泄露。
replaceFirst、replaceAll、replace区别
replaceFirst、replaceAll、replace是Java中常用的替换字符的方法,它们的方法定义是:
replace(CharSequence target,charSequence replacement),用replacement替换所有的target,两个参数都是字符串。
replaceAll(String regex, String replacement),用replacement替换所有的regex匹配项,regex是个正则表达式,replacement是字符串。
replaceFirst(String regex, String replacement),基本和replaceAll相同,区别是只替换第一个匹配项。
其中replaceAll以及replaceFirst是和正则表达式有关的,而replace和正则表达式无关。replaceAll和replaceFirst的区别主要是替换的内容不同,replaceAll是替换所有匹配的字符,而replaceFirst()仅替换第一次出现的字符串。
用法例子
1 | //replaceAll()替换符合正则的所有文字 |
1 | //replaceFirst()替换第一个符合正则的数据 |
1 | //replaceAll()替换所有html标签 |
1 | //replaceAll()替换指定文字 |
1 | //replace()替换字符串 |
String对“+”的重载
- String s = “a” + “b”,编译器会进行常量折叠(因为两个都是编译期常量,编译期可知),即变成String s = “ab”
- 对于能够进行优化的(String s = “a” + 变量 等)用StringBuilder的append()方法替代,最后调用toString()方法
字符串拼接的几种方式和区别
字符串拼接
String是Java中一个不可变的类,所以它一旦被实例化就无法被修改。不可变类的实例一旦创建,其成员变量的值就不能被修改。这样设计有很多好处,例如:可以缓存hashcode、使用更加便利以及更加安全。既然字符串是不可变的,那么字符串拼接又是怎么回事呢?
字符串不变性与字符串拼接
其实,所有的所谓字符串拼接,都是重新生成了一个新的字符串。
1 | String s = "abcd"; |
使用+拼接字符串
在Java中,拼接字符串最简单的方式就是直接使用符号+来拼接
1 | String wechat = "wk"; |
有人把Java中使用+拼接字符串的功能理解为运算符重载。其实并不是,Java是不支持运算符重载的。这其实只是Java提供的一个语法糖。
运算符重载:在计算程序设计中,运算符重载(operator overloading)是多态的一种。运算符重载,就是对已有的运算符重新进行定义,赋予其另一种能供,以适应不同的数据类型。
语法糖:语法糖(Syntactic sugar),指计算机语言中添加的某种语法,这种语法对语言功能没有影响,但是更方便程序员使用。语法糖让程序更加简洁,有更高的可读性。
Concat
1 | String wechat = "wk"; |
StringBuffer
用来定义字符串变量的StringBuffer类,它的对象是可以扩充和修改的。
1 | StringBuffer wechat = new StringBuffer("wk"); |
StringBuilder
1 | StringBuilder wechat = new StringBuilder("wk"); |
StringUtils.join
一些开源类库中提供的字符串拼接方法,如apache.commons中提供的StringUtils类,其中join方法可以拼接字符串。
1 | String wechat = "wk"; |
StringUtils中提供的join方法,最主要的功能是:将数组或集合以某种拼接符拼接到一起形成新的字符串,如:
1 | String[] list = {"wk","qweasd"}; |
并且,Java8中的String类中也提供了静态的join方法,用法和StringUtils.join类似。
使用+拼接字符串的实现原理
通过查看反编译以后的代码,我们可以发现,原来字符串常量在拼接过程中,是将String转成了StringBuilder后,使用其append方法进行处理。也就是说,Java中对+字符串的拼接,其实现原理是使用了StringBuilder.append。
concat是如何实现的
1 | public String concat(String str){ |
首先创建一个字符数组,长度是已有字符串和待拼接字符串的长度之和,再把两个字符串的值复制到新的字符数组中,并使用这个字符数组创建一个新的String对象并返回。经过concat方法,其实是new了一个新的String,也就呼应之前说的的字符串的不变性问题。
StringBuffer和StringBuilder
和String类类似,StringBuilder类也封装了一个字符数组
1 | char[] value; |
与String不同的是,它并不是final的,所以它是可以修改的。与String不同,字符数组中不一定所有位置都已经被使用,它有一个实例变量,表示数组中已经使用的字符个数,定义如下:
1 | int count |
1 | public StringBuilder append(String str){ |
该类继承了AbstractStringBuilder类,其append方法:
1 | public AbstractStringBuilder append(String str){ |
append会拷贝字符到内部的字符数组中,如果字符数组长度不够,会进行扩展。
StringBuffer和StringBuilder类似,最大的区别就是StringBuffer是线程安全的,看一下StringBuffer的append方法。
1 | public synchronized StringBuffer append(String str){ |
该方法使用synchronized进行声明,说明是一个线程安全的方法。而StringBuilder则不是线程安全的。
StringUtils.join是如何实现的
1 | public static String join(final Object[] array, String separator, final int startIndex, final int endIndex){ |
效率比较
1 | long t1 = System.currentTimeMillis(); |
从结果上看,用时从短到长的对比是:StringBuilder < StringBuffer < concat < + < StringUtils.join。
StringBuffer在StringBuilder的基础上,做了同步设置,所以耗时上会相对多一些。StringUtils.join也是使用了StringBuilder,并且其中还有很多其他操作,所以耗时较长。StringUtils.join更擅长处理字符数组或者列表的拼接。
其实使用+拼接字符串的实现原理也是使用的StringBuilder,那为什么结果相差这么多?在反编译后的代码中,每次都是new了一个StringBuilder,然后再把String转成StringBuilder,再进行append。
而频繁的新建对象当然要耗费很多时间了,不仅仅会浪费时间,频繁的创建对象,还会造成内存资源的浪费。所以,Java开发手册建议:循环体内,字符串的连接方式,使用StringBuilder的append方法进行扩展,而不要使用+。
总结
虽然字符串是不可变的,但是还是可以通过新建字符串的方式来进行字符串的拼接。常用的字符串拼接方式有五种,分别是StringBuilder、StringBuffer、concat、+、StringUtils.join。使用StringBuilder的方式是效率最高的,因为StringBuilder天生就是设计来定义可变字符串和字符串的变化操作的。
需要强调的是:
- 如果不是在循环体中进行字符串拼接的话,直接使用+就好了
- 如果在并发场景下进行字符串拼接,要使用StringBuff来代替StringBuilder
String.valueOf和Interger.toString的区别
我们有三种方式将一个int类型的变量变成String类型,那么有什么区别呢?
1 | int i = 5; |
第三行和第四行没有任何区别,因为String.valueOf(i)也是调用Integer.toString(i)来实现的。
第二行代码其实是String i1 = (new StringBuilder()).append(i).toString();首先创建一个StringBuilder对象,然后在调用append方法,再调用toString方法。
switch对String的支持
Java7中,switch的参数可以是String类型了,这对我们来说是一个很方便的改进。到目前为止switch支持这样几种数据类型:byte、short、int、char、String。
switch对整型支持的实现
1 | public class switchDemoInt{ |
反编译后的代码和之前的代码除了多了两行注释以外没有任何区别,那么我们就知道,switch对int的判断是直接比较整数的值。
switch对字符型支持的实现
1 | public class switchDemoChar{ |
对char类型进行比较的时候,实际上比较的是ascii码,编译器会把char型变量转换成对应的int型变量。
switch对字符串支持的实现
1 | public class switchDemoString{ |
字符串的switch是通过equals()和hashCode()方法来实现的。记住,switch中只能使用整型,比如byte,short,char(ascii码是整型)以及int。hashCode()方法返回的是int,而不是long。进行switch的实际是哈希值,然后通过使用equals方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。因此它的性能是不如使用枚举进行switch或者使用纯整数常量,但这也不差。因为java编译器只增加了一个equals方法,如果你比较的是字符串字面量的话会非常快,比如“abc” == “abc”。如果把hashCode()方法的调用也考虑进来,那么还会再多一次的调用开销,因为字符串一旦创建了,他就会把哈希值缓存起来。因此如果这个switch语句是用在一个循环里的,比如逐项处理某个值,或者游戏引擎循环地渲染屏幕,这里hashCode()方法的调用开销其实不会很大。
其实switch只支持一种数据类型,那就是整型,其他数据类型都是转换成整形之后在使用switch的。
字符串池
String作为一个Java类,可以通过以下两种方式创建一个字符串:
1 | String str = "qwe"; |
第一种是我们比较常用的做法,这种形式叫做“字面量”。在JVM中,为了减少相同的字符串的重复创建,为了达到节省内存的目的。会单独开辟一块内存,用于保存字符串常量,这个内存区域被称为字符串常量池。
当代码中出现双引号形式(字面量)创建字符串对象时,JVM会先对这个字符串进行检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回;否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。这种机制,就是字符串驻留或池化。
字符串常量池的位置
在JDK7之前的版本中,字符串常量池是放在永久代中的。
因为按照计划,JDK会在后续的版本中通过元空间来代替永久代,所以首先在JDK7中,将字符串常量池先从永久代中移出,暂时放到了堆内存中。
在JDK8中,彻底移除了永久代,使用元空间代替了永久代,于是字符串常量池再次从堆内存移动到永久代中。
Class 常量池
在Java体系中,共用三种常量池。分别是字符串常量池、Class常量池和运行时常量池。
什么是Class文件
计算机只认识0和1,所以程序员写的代码都需要经过编译成0和1构成二进制格式才能够让计算机运行。为了让Java语言具有良好的跨平台能力,Java独具匠心的提供了一种在所有平台上都使用的一种中间代码——字节码(ByteCode)。有了字节码,无论是哪种平台,只要安装了虚拟机,都可以直接运行字节码。同样,有了字节码,也解除了Java虚拟机和Java语言之间的耦合。目前Java虚拟机已经可以支持很多除了Java语言以外的语言了,之所以可以支持,这些语言也可以被编译成字节码。而虚拟机并不关心字节码是哪种语言编译而来的。
Java语言中负责编译出字节码的编译器是一个命令javac。javac是收录于JDK中的Java语言编译器。该工具可以将后缀名为.java的源文件编译为后缀名为.class的可以运行于Java虚拟机的字节码。
Class常量池
Class常量池也可以理解为Class文件中的资源仓库。Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Lieral)和符号引用(Symbolic Reference)。
由于不同的Class文件中包含的常量的个数是不固定的,所以在Class文件的常量池入口处会设置两个字节的常量池容量计数器,记录了常量池中常量的个数。
当然,还有一种比较简单的查看Class文件中常量池的方法,通过javap命令。
1 | javap -v HelloWorld.class |
常量池中有什么
常量池中主要存放两大类常量:字面量(literal)和符号引用(symbolic references)。
字面量
在计算机科学中,字面量(literal)是用于表达源代码中一个固定值的表示法(notation)。几乎所有计算机编程语言都具有对基本值的字面量表示,诸如:整数、浮点数以及字符串;而有很多也对布尔类型和字符类型的值也支持字面量表示;还有一些甚至对枚举类型的元素以及像数组、记录和对象等复合类型的值也支持字面量表示法。
简单的说,字面量就是指由字母、数字等构成的字符串或者数值。
字面量只可以右值出现,所谓右值是指等号右边的值,如: int a = 123这里a为左值,123为右值。在这个例子中123就是字面量。
1 | int a = 123; |
符号引用
常量池中,除了字面量以外,还有符号引用,那么到底什么是符号引用呢。符号引用是编译原理中的概念,是相对于直接引用来说的。主要包括以下三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
Class常量池有什么用
Class常量池是Class文件中的资源仓库,其中保存了各种常量。而这些常量都是开发者定义出来,需要在程序的运行期使用的。
Java代码在进行javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池得对应得符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
也就是说,Class是用来保存常量的一个媒介场所,并且是一个中间场所。在JVM真的运行时,需要把常量池中的常量加载到内存中。
运行时常量池
运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池(Constant_Pool)的运行时表示形式。它包括了若干种不同的常量:从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。运行时常量池扮演了类似传统语言中符号表(Symbol Table)的角色,不过它的存储数据范围比通常意义上的符号表要更为广泛。
每一个运行时常量池都分配在Java虚拟机的方法区中,在类和接口被加载到虚拟机后,对应的运行时常量池就被创建出来。以上就是Java虚拟机规范中关于运行时常量池的定义。
运行时常量在JDK各个版本中的实现
根据Java虚拟机规范约定:每一个运行时常量池都在Java虚拟机的方法区中分配,在加载类和接口到虚拟机后,就创建对应的运行时常量池。在不同版本的JDK中,运行时常量池所处位置也不一样,以HotSpot为例:
在JDK1.7之前,方法区位于堆内存的永久代中,运行时常量池作为方法区的一部分,也处于永久代中。因为使用永久代实现方法区可能导致内存泄露问题。
所以,从JDK1.7开始,JVM尝试解决这一问题,在1.7中将原本位于永久代中的运行时常量池移动到堆内存中。
在JDK1.8中,彻底移除了永久代,方法区通过元空间的方式实现。随之,运行时常量池也在元空间中实现。
运行时常量池中常量的来源
运行时常量池中包含了若干种不同的常量:编译期可知的字面量和符号引用(来自Class常量池)运行期解析后可获得的常量(如String的intern方法)。所以,运行时常量池中的内容包含:Class常量池中的常量、字符串常量池中的内容。
虚拟机启动过程中,会将各个Class文件中的常量池载入到运行时常量池中。所以,Class常量池只是一个媒介场所,在JVM真的运行时,需要把常量池中的常量加载到内存中,进入到运行时常量池。字符串常量池可以理解为运行时常量池分出来的部分。加载时,对于class的静态常量池,如果字符串会被装到字符串常量池中。
intern
在JVM中,为了减少相同的字符串的重复创建,为了达到节省内存的目的。会单独开辟一块内存,用于保存字符串常量,这个内存区域被叫做字符串常量池。
当代码中出现双引号形式创建字符串对象时,JVM会先对这个字符串进行检查,如果字符串常量池存在相同的字符串对象的引用,则将这个引用返回;否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。
除了以上方式之外,还有一种可以在运行期将字符串内容放置到字符串常量池的办法,那就是使用intern。在每次赋值的时候使用String的intern方法,如果常量池中有相同的值,就会重复使用该对象,返回对象引用。
String有没有长度限制
String的长度限制
想要搞清楚这个问题,首先我们需要翻阅一下String的源码,看下其中是否有关于长度的限制或定义。
String类中有很多重载的构造函数,其中有几个是支持用户传入length来执行长度的
1 | public String(byte bytes[], int offset, int length) |
可以看到,这里的参数length是使用int类型定义的,那么也就是说,String定义的时候,最大支持的长度就是int的最大范围值。根据Integer类的定义,java.lang.Integer#MAX_VALUE的最大值是$2^{31}-1$;那么,我们是否就可以认为String能支持的最大长度就是这个值了呢?
其实并不是,这个值只是在运行期,我们构造String的时候可以支持的一个最大长度,而实际上,在编译期,定义字符串的时候也是有长度限制的。
1 | String s = "11111...1111";//10万个字符"1" |
当我们使用如上形式一个字符串的时候,当我们执行javac编译时,会抛出异常的。那么,明明String的构造函数指定的长度是可以支持2147483647的,为什么上面的定义无法编译呢?
形如String s = “xxx”;定义String的时候,xxx被我们称之为字面量,这种字面量在编译之后会以常量的形式进入到Class常量池。那么问题就来了,因为要进入常量池,就要遵循常量池的有关规定。
常量池限制
javac是将Java文件编译成class文件的一个命令,那么在Class文件生成过程中,就需要遵守一定的格式。在《Java虚拟机规范》常量池的定义,CONSTANT_String_info用于表示java.lang.String类型的常量对象,格式如下:
1 | CONSTANT_String_info{ |
其中,string_index项的值必须是对常量池的有效索引,常量值在该索引处的项必须是CONSTANT_Utf8_info结构,表示一组Unicode码点序列,这组Unicode码点序列最终会初始化为一个String对象。
1 | CONSTANT_Utf8_info{ |
其中,length则指名了bytes[]数组长度,其类型为u2。u2表示为两个字节的无符号数,那么1个字节有8位,2个字节就有16位。16位无符号数可表示的最大值为$2^{16}-1=65535$。也就是说,Class文件中常量池的格式规定了,其字符串常量的长度不能超过65535。
1 | String s = "11111...1111";//有65535个字符“1” |
尝试使用javac编译,同样会得到“错误:常量字符串过长”,那么原因是什么?其实,这个原因在javac的代码中是可以找到的:
1 | private void checkStringConstant(DiagnosticPosition var1, Object var2){ |
代码中可以看出,当参数类型为String,并且长度大于等于65535的时候,就会导致编译失败。
如果我们尝试65534个字符定义字符串,则会发现可以正常编译。
运行期限制
上面提到的这种String长度的限制是编译期的限制,也就是使用String s = “”;这种字面值方式定义的时候才会有的限制。
那么,String在运行期有没有限制呢,答案是有的,就是我们前文提到的那个Integer.MAX_VALUE,这个值约等于4G,在运行期,如果String的长度超过这个范围,就可能会抛出异常。
1 | //2^31 - 1 = 2147483647 个 16-bit Unicodecharacter |
很多人会有疑惑,编译的时候最大长度都要求小于65535了,运行期怎么会出现大于65535的情况呢
1 | String s = ""; |
得到的字符长度就有10万。在系统对接中,需要传输高清图片,约定的传输方式是对方将图片转成BASE6编码,我们接受到之后再转成图片。在将BASE64编码后的内容赋值给字符串的时候就抛了异常。
总结
字符串有长度限制,在编译期,要求字符串常量池中的常量不能超过65535,并且在javac执行过程中控制了最大值为65534。在运行期,长度不能超过int的范围,否则会抛异常。
自动拆/装箱的实现
自动拆/装箱
基本数据类型
基本类型,或者叫做内置类型,是Java中不同于类(Class)的特殊类型。它们是我们编程中使用最频繁的类型。
Java是一种强类型语言,第一次申明变量必须说明数据类型,第一次变量赋值称为变量的初始化。
Java基本类型共有八种,基本类型可以分为三类:
字符类型 char
布尔类型 boolean
数值类型 byte、short、int、long、float、double
Java中的数值类型不存在无符号的,它们的取值范围是固定的,不会随着机器环境或者操作系统的改变而改变。实际上,Java中还存在另外一种基本类型void,它有对应的包装类java.lang.Void,不过我们无法直接对它们进行操作。
基本数据类型有什么好处
我们都知道在Java语言中,new一个对象是存储在堆里的,我们通过栈中的引用来使用这些对象;所以,对象本身来说是比较消耗资源的。
对于经常用到的类型,如int等,如果我们每次使用这种变量的时候都需要new一个Java对象的话,就会比较笨重。所以,和C++一样,Java提供了基本数据类型,这种数据的变量不需要使用new创建,它们不会在堆上创建,而是直接在栈内存中存储,因此会更加高效。
整型的取值范围
Java中整型主要包含byte、short、int和long这四种,表示的数字范围也是从小到大的,之所以表示范围不同主要和他们存储数据时所占的字节数有关。详见
包装类型
Java语言是一个面向对象的语言,但是Java中的基本数据类型却不是面向对象的,这在实际使用时存在很多的不便,为了解决这个不足,在设计类时为每个基本数据类型设计了一个对应的类进行代表,这样八个和基本数据类型对应的类统称为包装类(Wrapper Class)。
基本数据类型 | 包装类 |
---|---|
byte | Byte |
boolean | Boolean |
short | Short |
char | Character |
int | Integer |
long | Long |
float | Float |
double | Double |
为什么需要包装类
因为Java是一种面向对象语言,很多地方都需要使用对象而不是基本数据类型。比如,在集合类中,我们无法将int、double等类型放进去的。因为集合的容器要求元素是Object类型。
为了让基本类型也具有对象的特征,就出现了包装类型,它相当于将基本类型“包装起来”,使得它具有了对象的性质,并且为其添加属性和方法,丰富了基本类型的操作。
拆箱与装箱
那么,有了基本数据类型和包装类,肯定有些时候要在他们之间进行转换。比如把一个基本数据类型的int转换成包装类型Integer对象。
我们认为包装类是对基本类型的包装,所以,把基本数据类型转换成包装类的过程就是打包装,英文对应于boxing,中文翻译为装箱。
反之,把包装类转换成基本数据类型的过程就是拆包装,英文对应于unboxing,中文翻译为拆箱。
在JavaSE5之前,要进行装箱,可以通过以下代码:
1 | Integer i = new Integer(10); |
自动拆箱与自动装箱
在JavaSE5中,为了减少开发人员的工作,Java提供了自动拆箱和自动装箱功能。
- 自动装箱:就是将基本数据类型自动转换成对应的包装类
- 自动拆箱:就是将包装类自动转换成对应的基本数据类型
1 | Integer i = 10; |
就是因为Java帮助我们提供了自动装箱的功能,不需要开发者手动new一个Integer对象
自动装箱与自动拆箱的实现原理
既然Java提供了自动拆箱的能力,那么,我们就来看一下,到底是什么原理,Java是如何实现的自动拆箱功能。
1 | public static void main(String[] args){ |
对以上代码进行反编译后可以得到以下代码:
1 | public static void main(String[] args){ |
int的自动装箱是通过Integer.valueOf()方法来实现的,Integer的自动拆箱都是通过integer.intValue来实现的。即自动装箱都是通过包装类的valueOf()方法来实现的,自动拆箱都是通过包装类的对象的xxxValue()来实现的。
哪些地方会自动拆装箱
- 将基本数据类型放入集合类
1 | List<Integer> li = new ArrayList<>(); |
- 包装类型和基本类型的大小比较
1 | Integer a = 1; |
包装类与基本类进行数据类型进行比较运算,是先将包装类进行拆箱成基本数据类型,然后进行比较的。
- 包装类型的运算
1 | Integer i = 10; |
两个包装类型之间的运算,会被自动拆箱成基本类型进行。
- 三目运算符的使用
1 | boolean flag = true; |
当第二,第三位操作数分别为基本类型和对象时,其中的对象就会拆箱为基本类型进行操作。在例子中,第二段i是一个包装类型的对象,而第三段的j是一个基本类型,所以会对包装类进行自动拆箱。如果这个时候i的值为null,那么就会发生NPE。
- 函数参数与返回值
1 | public int getNum1(Integer num){ |
- 自动拆箱与缓存
1 | public static void main(String... strings){ |
我们普遍认为上面的两个判断的结果都是false。虽然比较的值都是相等的,但是由于比较的是对象,而对象的引用不一样,所以会认为if判断都是false的。在Java中,==比较的的是对象的引用,而equals比较的是值。所以,在这个例子中,不同的对象有不同的引用,所以在进行比较的时候都将返回false。奇怪的是,这里两个类似的if条件判断返回不同的布尔值。
原因就和Integer中的缓存机制有关。在Java5中,在Integer的操作上引入了一个新功能来节省内存和提高性能。整型对象通过使用相同的对象引用实现了缓存和重用。
适用于整数值-128~+127
只适用于自动装箱。使用构造函数创建对象不适用。
我们只需要知道,当需要进行自动装箱时,如果数字在-128~127之间时,会直接使用缓存中的对象,而不是重新创建一个对象。其中最大值127可以通过-XX:AutoBoxCacheMax=size修改。在Java5中引入的时候,范围是固定的-128至127。后来在Java6中,可以通过java.lang.Integer.IntegerCache.high设置最大值。到底是什么原因选择这-128到127范围呢?因为这个范围的数字是最被广泛使用的。在程序中,第一次使用Integer的时候也需要一定的额外时间来初始化这个缓存。
在Boxing Conversion部分的Java语言规范(JLS)规定如下:
如果一个变量p的值是:
1 | //-128至127之间的整数 |
范围内,将p包装成a和b两个对象,可以直接使用a==b判断a和b的值是否相等。
自动拆装箱带来的问题
当然,自动拆装箱是一个很好的功能,大大的节省了开发人员的精力,不再需要关心到底什么时候需要拆装箱。但是,他也会引入一些问题。
包装对象的数值比较,不能简单的使用==,虽然-128至+127之间的数字可以,但是这个范围之外还是需要使用equals比较。
前面提到的,有些场景会进行自动拆装箱,同时也说了,由于自动拆箱,如果包装类对象为null,那么自动拆箱时就有可能抛出NPE。
如果一个for循环中有大量的拆箱操作,会浪费很多资源。
Integer的缓存机制
这是在Java5中引入的一个有助于节省内存、提高性能的功能。
Java中Integer的缓存实现
在Java5中,在 Integer的操作上引入了一个新功能来节省内存和提高性能。整型对象通过使用相同的对象引用实现了缓存和重用。适用于整数值区间-128至+127。只适用于自动装箱,使用构造函数创建对象不适用。
Java的编译器把基本数据类型自动转换成封装类对象的过程叫做自动装箱,相当于使用valueOf方法:
1 | Integer a = 10; |
JDK中的valueOf方法,下面是JDK1.8.0 build 25的实现:
1 | public static Integer valueOf(int i){ |
在创建对象之前先从IntegerCache.cache中寻找。如果没找到才使用new新建对象。
IntegerCache Class
IntegerCache Class 是Integer类中定义的一个private static的内部类。
1 | private static class IntegerCache{ |
其中的javadoc详细说明了缓存支持-128到127之间的自动装箱过程。最大值127可以通过-XX:AutoBoxCacheMax=size修改。缓存通过一个for循环实现,从低到高创建尽可能多的整数并存储在一个整数数组中。这个缓存会在Integer类第一次被使用的时候被初始化出来。以后,就可以使用缓存中包含的实例对象,而不是创建一个新的实例对象。
实际上这个功能在Java6中引入的时候,范围是-128至127,后来在Java6中,可以通过java.lang.Integer.IntegerCache.high设置最大值。这使得我们可以根据应用程序的实际情况灵活地调整来提高性能。到底是什么原因选择这个-128到+127范围呢?因为这个范围的数字是最被广泛使用的。在程序中,第一次使用Integer的时候也需要一定的额外时间来初始化这个缓存。
Java语言规范中的缓存行为
在Boxing Conversion部分的Java语言规范(JLS)规定如下:
如果一个变量p的值是:
1 | //-128至127之间的整数 |
范围内,将p包装成a和b两个对象,可以直接使用a==b判断a和b的值是否相等。
其他缓存的对象
这种缓存行为不仅适用于Integer对象。我们针对所有的整数类型都有类似的缓存机制。
- ByteCache用于缓存Byte对象
- ShortCache用于缓存Short对象
- LongCache用于缓存Long对象
- CharacterCache用于缓存Character对象
Byte,Short,Long有固定的范围-128到127.对于Character,范围是0到127.除了Integer以外,这个范围都不能改变。
如何正确定义接口的返回值(boolean/Boolean)类型及命名(success/isSuccess)
在日常开发中,我们经常要在类中定义布尔类型的变量,比如在给外部系统中提供一个RPC接口的时候,我们一般会定义一个字段表示本次请求是否成功。
关于这个“本次请求是否成功”的字段的定义,一般情况下,我们可以有以下四种方式来定义一个布尔类型的成员变量:
1 | boolean success |
前两种和后两种的主要区别是变量的类型不同,前者使用的是boolean,后者使用的是Boolean。另外,第一种和第三种在定义变量的时候,变量名为success,而另外两种使用的是isSuccess来命名。
success还是isSuccess
在Java开发手册中关于这一点,有一个【强制性】规定:
POJO类中布尔类型的变量,都不要加is,否则部分框架解析会引起序列化错误。反例:定义为基本数据类型boolean isSuccess;的属性,它的方法也是isSuccess(),RPC框架在反向解析的时候,“以为”对应的属性名称是success,导致属性获取不到,进而抛出异常。
我们看一下POJO中布尔类型变量不同的命名有什么区别
1 | class Model1{ |
以上的代码的setter/getter都是IDEA自动生成的,仔细观察你会发现:
基本类型自动生成getter和setter方法名称都是isXXX()和setXXX()形式
包装类型自动生成getter和setter方法名称都是getXXX()和setXXX()形式
既然,我们已经达成一致共识使用基本类型boolean来定义成员变量,那么我们再来看Model3和Model4中setter/getter有何区别。
我们可以发现,虽然Model3和Model4中成员变量的名称不同,但是他们自动生成的getter和setter反方法名都是isSuccess和setSuccess。
Java Bean中关于setter/getter的规范
关于Java Bean中的getter/setter方法的定义有明确的规定,如果是普通参数propertyName,要以下方式定义其setter/getter:
1 | public <PropertyType> get<PropertyName>(); |
但是,布尔类型的变量peopertyName则单独定义的:
1 | public boolean is<PropertyName>(); |
通过这份Java Beans规范,我们发现,再Model4中,变量名为isSuccess,如果严格按照规范定义的话,它的getter方法应该叫isIsSuccess。但是很多IDE都会默认生成为isSuccess。
那么这样做会带来什么问题的?在一般情况下,其实是没有什么影响的,但是有一种特殊情况就会有问题,那就是发生序列化的时候。
序列化带来的影响
我们使用比较常用的JSON序列化来举例,看看常用的fastJson、jackson和Gson之间有何区别:
1 | public class BooleanMainTest{ |
以上代码Model3中,只有一个成员变量即isSuccess,三个方法,分别是IDE自动生成 的isSuccess和setSuccess,另外是自己增加的一个符合getter命名规范的方法。
1 | Serializable Result With fastJson : {"qwe":"qweb","success":true} |
在fastjson和jackson的结果中,原来类中的isSuccess字段被序列化成success,并且其中还包含qwe值。而Gson中只有isSuccess字段。
我们可以得出结论:fastjson和jackson把对象序列化成json字符串的时候,是通过反射遍历出该类中的所有getter方法,得到getqwe和isSuccess,然后根据JavaBeans规则,他会认为这是两个属性qwe和success的值。直接序列化成json:{“qwe”:”qweb”,”success”:true}。但是Gson并不是这么做的,它是通过反射遍历类中的所有属性,并把其值序列化成json:{“isSuccess”:true}。
可以看到,由于不同的序列化工具,在进行序列化的时候使用到的策略是不一样的,所以,对于同一个类的同一个对象的序列化结果可能不同的。
现在,不同的序列化框架得到的json内容并不相同,如果对于同一个对象,我们使用fastjson进行序列化,再使用Gson反序列化会发生什么?
1 | public class BooleanMainTest{ |
这和我们预期的结果完全相反,原因是因为JSON框架通过扫描所有getter后发现有一个isSuccess方法,然后根据JavaBeans的规范,解析出变量名为success,把model对象序列化成字符串后内容为{“success”:true}
根据{“success”:true}这个json串,Gson框架在通过解析后,通过反射寻找model类中success属性,但是Model类中只有isSuccess属性,所以,最终反序列化后的Model类中的对象中,isSuccess则会使用默认值false。
但是,以上代码发生在生产环境,这绝对是一个致命的问题。
所以,作为开发者,我们应该想办法尽量避免这种问题的发生,对于POJO的设计者来说,只需要做简单的一件事就可以解决这个问题了,那就是把isSuccess改为success。这样,该类里面的成员变量时success,getter方法是isSuccess,这是完全符合JavaBeans规范的。无论哪种序列化框架,执行结果都一样。这样从源头避免了这个问题。
所以,在定义POJO中的布尔类型的变量时,不要使用isSuccess这种形式,而要使用success!
Boolean还是boolean
boolean是基本数据类型,而Boolean是包装类型。那么,在定义一个成员变量的时候到底使用包装类型更好还是使用基本数据类型呢?
1 | public class BooleanMainTest{ |
可以看到,当我们没有设置Model对象的字段的值的时候,Boolean类型的变量会设置默认值为null,而boolean类型的变量会设置默认值为false。
即对象的默认值是null,boolean基本数据类型的默认值为false。
在Java开发手册中,对于POJO中如何选择变量的类型也有着一些规定:
关于基本数据类型与包装类型的使用标准如下:
- 【强制】所有的POJO类属性必须使用包装数据类型
- 【强制】RPC方法的返回值和参数必须使用包装数据类型
- 【推荐】所有的局部变量使用基本数据类型
说明:POJO类属性没有初值是提醒使用者在需要使用时,必须自己显示地进行赋值,任何NPE问题,或者入库检查,都由使用者来保证。
正例:数据库的查询结果可能是null,因为自动拆箱,用基本数据类型接收有NPE风险。
反例:比如显示成交总额涨跌情况,即正负x%,x为基本数据类型,调用的RPC服务,调用不成功时,返回的是默认值,页面显示为0%,这是不合理的,应该显示成中划线。所以包装数据类型的null值,能够表示额外的信息,如:远程调用失败,异常退出。
举一个扣费的例子,做一个扣费系统,扣费时需要从外部的定价系统中读取一个费率的值,我们预期该接口的返回值中会包含一个浮点型的费率字段。当我们取到这个值的时候就使用公式:金额*费率=费用 进行计算,计算结果进行划扣。
如果由于计费系统异常,它可能会返回一个默认值,如果这个字段是Double类型的话,该默认值为null,如果该字段是double类型的话,该默认值为0.0
如果扣费系统对于该费率返回值没做特殊处理的话,拿到null值进行计算会直接报错,阻断程序。拿到0.0可能就直接进行计算,得出接口0后进行扣费了。这种异常情况就无法被感知。
这种使用包装类型定义变量的方式,通过一异常来阻断程序,进而可以被识别到这种线上问题。如果使用基本数据类型的话,系统可能不会被报错,进而认为无异常。
以上,就是建议在POJO和RPC的返回值中使用包装类型的原因。
总结
在定义一个布尔类型的变量,尤其是一个给外部提供的接口返回值时,要使用success来命名,Java开发手册建议使用包装类来定义POJO和RPC返回值中的变量。但是这不是意味着可以随意的使用null,我们还是要尽量避免出现对null的处理。
异常处理
Error和 Exception
Exception和Error,两者都是Java异常处理的重要子类,各自都包含大量子类,均继承自Throwable类。
Error表示系统及错误,是Java运行环境内部错误或者硬件问题,不能指望程序来处理这样的问题,除了提出运行外别无选择,它是Java虚拟机抛出的异常。
Exception表示程序需要捕捉、需要处理的异常,是由程序设计的不完善而出现的问题,程序必须处理的问题。
异常类型
Java中的异常,主要可以分为两大类,即受检异常(checked exception)和非受检异常(unchecked exception)。
受检异常
对于受检异常,如果一个方法在声明中声明了其要有受检异常的抛出
1 | public void test() throw new Exception{} |
那么,我们在程序中调用他的时候,一定要对该异常进行处理(捕获或者向上抛出),否则是无法编译通过的,这是一种强制规范。
这种异常在IO操作中比较多。比如FileNotFoundException,当我们使用IO流处理一个文件的时候,有一种特殊情况,就是文件不存在,所以,在文件处理的接收定义时它会显示抛出FileNotFoundException,目的就是告诉这个方法的调用者,我这个方法不保证一定可以成功,是有可能找到对应的文件,你要明确的对这种情况做特殊的处理。
所以说,当我们希望我们的方法调用者,明确的处理一些特殊情况的时候,就应该使用受检异常。
非受检异常
对于非受检异常来说,一般是运行时异常,继承自RuntimeException。在编写代码的时候,不需要现实的捕获,但是如果不捕获,在运行期如果发生异常就会中断程序的执行。
这种异常一般可以理解为是代码原因导致的,比如发生空指针、数组越界等。所以,只要代码写的没问题,这些异常都是可以避免的,也就不需要我们显示地进行处理。
试想一下,如果你要对所有可能发生空指针的地方做异常处理的话,那相当于你的所有代码都需要做这件事。
异常相关关键字
throws、throw、try、catch、finally
- try:用来指定一块预防所有异常的程序
- catch子句紧跟在try块后面,用来指定你想要捕获的异常的类型
- finally:为确保一段代码不管发生什么异常情况都要被执行
- throw:用来明确地抛出一个异常
- throws:用来声明一个方法可能抛出的各种异常
正确处理异常
异常的方式有两种:1.自己处理;2.向上抛,交给调用者处理
异常,千万不能捕获之后什么也不做,或者只是使用e.printStacktrace
具体的处理方式的选择其实原则比较简明:自己明确的知道如何处理,就要处理掉;不知道如何处理的,就交给调用者处理。
自定义异常
自定义异常就是开发人员自己定义的异常,一般通过继承Exception的子类的方式实现。
编写自定义异常类实际上是继承一个API标准异常类,用新定义的异常处理信息覆盖原有信息的过程。
这种用法在Web开发中也比较常见,一般可以用自定义业务异常。如余额不足、重复提交。这种自定义异常也有业务含义,更容易让上层理解和处理。
异常链
“异常链”是Java中非常流行的异常处理概念,是指在进行一个异常处理时抛出了另外一个异常,由此产生了一个异常链条。
该技术大多用于将“受检查异常”(checked exception)封装成“非受检查异常”(unchecked exception)或者RuntimeException。
如果因为因为异常你决定抛出一个新的异常,你一定要包含原有的异常,这样,处理程序才可以通过getCause()和initCause()方法来访问异常最终根源。
从Jaava1.4版本开始,几乎所有的异常都支持异常链。
以下是Throwable中支持异常链的方法和构造函数。
1 | Throwable getCause() |
initCause和Throwable构造函数的Throwable参数是导致当前异常的异常。getCsuse返回导致当前异常的异常,initCause设置当前异常的原因。
以下显示如何使用异常链。
1 | try{ |
当捕获的IOException时,将创建一个新的SampleException异常,并附加原始的异常原因,并将异常链抛出到下一个更高级别的异常处理程序。
try-with-resources
Java里,对于文件操作IO流,数据库连接等开销非常昂贵的资源,用完之后必须及时通过close方法将其关闭,否则资源会一直处于打开状态,可能会导致内存泄漏等问题。
关闭资源的常用方法就是在finally块里释放,即调用close方法。例如:
1 | public static void main(String[] args){ |
从Java7开始,JDK提供了一种更好的方式关闭资源,使用try-with-resources语句,改写一下上面的代码:
1 | public static void main(String... args){ |
看下它的背后:
1 | public static transient void main(String[] args){ |
那些我们没有做的关闭资源的操作,编译器都帮我们做了。语法糖的作用就是方便程序员的使用,但最终还是要转成编译器认识的语言。
finally和return的执行顺序
try()里面有一个return语句,那么后面的finally{}里面的code会不会被执行,什么时候执行,是在return前还是return后?
如果try中有return语句,那么finally中的代码还是会执行。因为return表示的是要整个方法体返回,所以,finally中的语句会在return之前执行。
但是return前执行的finally块内,对数据修改效果对于引用类型和值类型会不同:
1 | static int f(){ |
集合类
Collection和Collections区别
Collection是一个集合接口,它提供了对集合对象进行基本操作的通用接口方法。Collection接口在java类库中有很多具体的实现,是list,set等的父接口。
Collections是一个包装类,它包含了各种有关集合操作的静态多态方法。此类不能实例化,就像一个工具类,服务于Java的Collection框架。
日常开发中,不仅要了解Java中的Collection及其子类的用法,还要了解Collections用法。可以提升很多处理集合类的效率。
Set和List区别
List,Set都是继承自Collection接口,都是用来存储一组相同类型的元素的。
List特点:元素有放入顺序,元素可重复。有顺序即先放入的元素排在前面。
Set特点:元素无放入顺序,元素不可重复。无顺序即先放入的元素不一定排在前面,不可重复即相同元素在set中只会保留一份。所以,有些场景下,set可以用来去重。不过需要注意的是,set在元素插入时是要有一定的方法来判断元素是否重复的。这个方法很重要,决定了set中可以保存哪些元素。
ArrayList和LinkedList和Vector的区别
List主要有ArrayList、LinkedList和Vector几种实现。
这三者都实现了List接口,使用方式也很相似,主要区别在于因为实现方式的不同,所以对不同的操作具有不同的效率。
ArrayList是一个可以改变大小的数组。当更多的元素加入到ArrayList中,其大小将会动态地增长,内部的元素可以直接通过get与set方法进行访问,因为ArrayList本质上就是一个数组。
LinkedList是一个双链表,在添加和删除时具有比ArrayList更好的性能,但get与set方面弱于ArrayList。
当然,这些对比都是指数据量很大或者操作很频繁的情况下的对比,如果数据量和运算量很小,那么对比将失去意义。
Vector和ArrayList类似,但属于强同步类,如果你的程序本身是线程安全的(thread-safe,没有多个线程之间共享一个集合/对象),那么使用ArrayList是更好的选择。
Vector和ArrayList在更多元素添加进来时会请求更大的空间,Vector每次请求其大小的双倍空间,而ArrayList每次对size增长50%。
LinkedList还实现了Queue接口,该接口比List提供了更多的方法,包括offer(),peek(),poll()等
默认情况下ArrayList的初始容量非常小,所以如果可以预估数据量的话,分配一个较大的初始值属于最佳实践,这样可以减少调整大小的开销。
ArrayList使用了transient关键字进行存储优化,而Vector没有,为什么?
1 | //ArrayList |
ArrayList实现了writeObject方法,可以看到只保存了非null的数组位置上的数据。即list的size个数的elementData。需要额外注意的是,ArrayList的实现,提供了fast-fail机制,可以提供弱一致性。
1 | //Vector |
Vector也实现了writeObject方法,但方法并没有实现像ArrayList一样进行优化存储,实现语句是:
1 | data = elementData.clone(); |
clone()的时候会把null值也拷贝,所以保存相同内容的Vector与ArrayList,Vector的占用的字节比ArrayList要多。可以测试一下,序列化存储相同内容的Vector与ArrayList,分别到一个文本文件中。Vector需要243字节;ArrayList需要135字节。
ArrayList是非同步实现的一个单线程下较为高效的数据结构(相比Vector)。ArrayList只通过一个修改记录字段提供弱一致性,主要用在迭代器里,没有同步方法。即上面提到的Fast-fail机制。ArrayList的存储结构定义为transient,重写writeObject来实现自定义的序列化,优化了存储。
Vector是多线程环境下更为可靠的数据结构,所有方法都实现了同步。
区别
同步处理:Vector同步,ArrayList非同步,Vector缺省情况下增长原来一倍的数组长度,ArrayList是0.5倍。ArrayList:int newCapacity = oldCapacity + (oldCapacity >> 1);ArrayList自动扩大容量为原来1.5倍(实现的时候,方法会传入一个期望的最小容量,若扩容后容量仍然小于最小容量,那么容量就为传入的最小容量。扩容的时候使用的Arrays.copyOf方法最终调用native方法进行新数组创建和数据拷贝)。Vector:int newCapacity = oldCapacity + ((capacityIncrement > 0)? capacityIncrement : oldCapacity); Vector指定了initialCapacity,capacityIncrement来初始化的时候,每次增长capacityIncrement。
SynchronizedList和Vector的区别
Vector是java.util包中的一个类,SynchronizedList是java.util.Collections中的一个静态内部类。
在多线程的场景中可以直接使用Vector类,也可以使用Collections.synchronizedList(List list)方法来返回一个线程安全的List。那么,SynchronizedList和Vector有什么区别呢?为什么Java api要提供这两种线程安全的List的实现方式呢?
首先,Vector和ArrayList都是List的子类,他们底层的实现都是一样的。所以这里比较如下list1和list2的区别:
1 | List<String> list = new ArrayList<String>(); |
一、比较几个重要的方法
- add方法
1 | //Vector的实现 |
1 | //synchronizedList的实现 |
这里,使用同步代码块的方式调用ArrayList的add()方法。
1 | //ArrayList的add方法 |
从上面的两段代码中发现有两处不同:1.Vector使用同步方法实现,synchronizedList使用同步代码块实现;2.两者扩充数组容量方式不一样(add方法在扩容方面的差别就是ArrayList和Vector的差别)。
- remove方法
1 | //synchronizedList的实现 |
1 | //ArrayList类的remove方法 |
1 | //Vector的实现 |
从remove方法中我们发现除了一个使用同步方法,一个使用同步代码块之外几乎无任何区别。
通过比较其他方法,我们发现,SynchronizedList里面实现的方法几乎都是使用同步代码块包上List的方法。如果该List是ArrayList,那么,SynchronizedList和Vector的一个比较明显区别就是一个使用了同步代码块。一个使用了同步方法。
二、区别分析
数据增长区别
从内部实现机制来讲ArrayList和Vector都是使用数组(Array)来控制集合中的对象。当你向这两种类型中增加元素的时候,如果元素的数目超出了内部数组目前的长度它们都需要扩展内部数组长度,vector缺省情况下自动增长原来一倍的长度,ArrayList是原来的50%,所以最后你获得的这个集合所占的空间总是比你实际需要的要大。所以如果你要在集合中保存大量的数据那么使用Vector有一些优势,因为你可以通过设置集合的初始化大小来避免不必要的资源开销。
同步代码块和同步方法的区别
- 同步代码块在锁定的范围上可能比同步方法要小,一般来说锁的范围大小和性能是成反比的。
- 同步块可以更加精确的控制锁的作用域(所的作用域就是从锁被获取到其被释放的时间),同步方法的锁的作用域就是整个方法。
- 静态代码块可以选择对哪个对象加锁,但是静态方法只能给this对象加锁。
因为SynchronizedList只是使用同步代码块包裹了ArrayList的方法,而ArrayList和Vector中同名方法的方法体内容并无太大差异,所以在锁定范围和锁的作用域上两者并无区别。在锁定的对象区别上,SynchronizedList的同步代码块锁定的是mutex对象,Vector锁定的是this对象。那么mutex对象又是什么呢?其实SynchronizedList有一个构造函数可以传入一个Object,如果在调用的时候显示的传入一个对象,那么锁定的就是用户传入的对象。如果没有指定,那么锁定的也是this对象。
所以,SynchronizedList和Vector的区别目前为止有两点:1.如果使用add方法,它们的扩容机制不一样;2.SynchronizedList可以指定锁定的对象。
但是,SynchronizedList中实现的类并没有都使用synchronized同步代码块。其中有listIterator和listIterator(int index)并没有做同步处理。但是Vector却对该方法加了方法锁。所以在使用SynchronizedList进行遍历的时候要手动加锁。
之前的比较都是基于将ArrayList转成SynchronizedList。那么如果我们把LinkedList变成线程安全的,或者说我想要方便在中间插入和删除的同步的链表,那么我们可以将已有的LinkedList直接转成SynchronizedList,而不用改变它的底层的数据结构。而这一点是Vector无法做到的,因为他的底层是使用数组实现的,这个是无法更改的。
所以,最后,SynchronizedList和Vector最主要的区别:1.SynchronizedList有很好的扩展和兼容功能。它可以将所有的List子类转换成线程安全的类。2.使用SynchronizedList的时候,进行遍历的时候要手动进行同步处理。3.SynchronizedList可以指定锁定的对象。
Set如何保证元素不重复?
在Java的Set体系中,根据实现方式不同主要分为两大类:HashSet和TreeSet。
- TreeSet是二叉树实现的,TreeSet中的数据是自动排好序的,不允许放入null值
- HashSet是哈希表实现的,HashSet中的数据是无需的,可以放入null,但只能放入一个null,两者中的值都不能重复,就如数据库中的唯一约束。
在HashSet中,基本的操作都是由HashMap底层实现的,因为HashSet底层是用HashMap存储数据的。当向HashSet中添加元素的时候,首先计算元素的hashcode值,然后通过扰动计算和按位与的方式计算出这个元素的存储位置,如果这个位置为空,就将元素添加进去;如果不为空,则用equals方法比较元素是否相等,相等就不添加,否则找一个空位添加。
TreeSet的底层是TreeMap的keySet(),而TreeMap是基于红黑树实现的,红黑树是一种平衡二叉查找树,它能保证任何一个节点的左右子树的高度差不会超过较矮的那棵的一倍。
TreeMap是按key排序的,元素在插入TreeSet时compareTo()方法要被调用,所以TreeSet中的元素要实现Comparable接口。TreeSet作为一种Set,它不允许出现重复元素。TreeSet是用compareTo()来判断重复元素。
HashMap、HashTable、ConcurrentHashMap区别
HashMap和HashTable有何不同?
线程安全:HashTable中的方法是同步的,而HashMap中的方法在默认情况下是非同步的。在多线程并发的环境下,可以直接使用HashTable,但是要使用HashMap的话就要自己增加同步处理了。
继承关系:HashTable是基于陈旧的Dictionary类继承而来的。HashMap继承的抽象类AbstractMap实现了Map接口。
允不允许null值:HashTable中,key和value都不允许出现null值,否则会抛出NPE异常。HashMap中,null可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为null。
默认初始容量和扩容机制:HashTable中的hash数组初始大小是11,增加的方式是old*2+1。HashMap中的hash数组的默认大小为16,而且一定是2的指数。
哈希值的使用不同:HashTable直接使用对象的hashCode,HashMap重新计算hash值。
遍历方式的内部实现上不同:HashTable、HashMap都使用了Iterator。而由于历史原因,HashTable还使用了Enumeration的方式,HashMap实现了Iterator,支持fast-fail,HashTable的Iterator遍历支持fast-fail,用Enumeration不支持fast-fail。
HashMap和ConcurrentHashMap的区别?
ConcurrentHashMap和HashMap的实现方式不一样,虽然都是使用桶数组实现的,但是还是有区别的,ConcurrentHashMap对桶数组进行了分段,而HashMap并没有。
ConcurrentHashMap在每一个分段上都用锁进行了保护。HashMap没有锁机制,所以前者线程安全,后者不是线程安全的。
以上区别基于JDK1.8以前的版本。
HashMap的容量、扩容
HashMap类中有以下主要成员变量:
- transient int size:记录了Map中KV对的个数
- loadFactor:装载因子,用来衡量HashMap满的程度,loadFactor的默认值为0.75f(static final float DEFAULT_LOAD_FACTOR = 0.75f)
- int threshold:临界值,当实际KV个数超过threshold时,HashMap会将容量扩容,threshold=容量* 装载因子
- capacity:容量,如果不指定,默认容量为16(static final int DEFAULT_INITIAL_CAPACITY = 1 <<4;)
size和capacity
HashMap中的size和capacity之间的区别其实解释起来也挺简单的,HashMap就像一个“桶”,那么capacity就是这个桶当前最多可以装多少元素,而size表示这个桶已经装了多少元素。
1 | Map<String, String> map = new HashMap<String, String>(); |
我们定义了一个新的HashMap,并想其中put一个元素,然后同过反射的方式 打印capacity和size。输出结果为:capacity:16、size:1。
默认情况下,一个HashMap的容量(capacity)是16,设计为16的好处主要是可以使用按位与代替去模来提升hash的效率。
HashMap是具有扩容机制的,在一个HashMap第一次初始化的时候,默认情况下它的容量是16,当达到了扩容条件的时候,就需要进行扩容了,会从16扩容到32。
HashMap的重载的构造函数中,有一个是支持传入initialCapacity的。
1 | Map<String, String> map = new HashMap<String, String>(1); |
分别执行以上3段代码,分别输出:capacity:2、capacity:8、capacity:16
也就是说,默认情况下HashMap的容量是16,但是,如果用户通过构造函数指定了一个数字作为容量,那么Hash会选择大于该数字的第一个2的幂作为容量。(1->2、7->8、9->16)
这里有一个小建议:在初始化HashMap的时候,应该尽量指定其大小,尤其是当你已知map中存放的元素个数时。
loadFactor和threshold
HashMap有扩容机制,就是当达到扩容条件时会进行扩容,从16扩容到32、64、128…那么,这个扩容条件指的是什么呢?
其实,HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor * capacity。loadFactor是装载因子,表示HashMap满的程度,默认值为0.75f,设置为0.75有一好处,那就是0.75正好是3/4,而capacity又是2的幂。所以,两个数的乘积都是整数。
对于一个默认的HashMap来说,默认情况下,当期size大于12(16*0.75)时就会触发扩容。
1 | Map<String, String> map = new HashMap<>(); |
1 | capacity : 16 |
当HashMap中的元素个数达到了13的时候,capacity就从16扩容到32了。HashMap中还提供了一个在支持传入initialCapacity,loadFactor两个参数的方法,来初始化容量和装载因子。不过,一般不建议修改loadFactor的值。
总结
HashMap中size表示当前共有多少个KV对,capacity表示当前HashMap的容量是多少,默认值是16,每次扩容都是成倍的。loadFactor是装载因子,当Map中元素超过了loadFactor*capacity的值时,会触发扩容。loadFactor*capacity可以用threshold表示。
HashMap中hash方法的原理
哈希
Hash,一般翻译做“散列”,也直接音译为“哈希”的,就是把任意长度的输入,通过散列算法,变成固定长度的输出,该输出就是散列值。这是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入的值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列值函数计算出的散列值如果相同,输出值不一定相同。
若两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞。
常见的Hash函数有以下几个:
- 直接定址法:直接以关键字k或者k加上某个常数(k+c)作为哈希地址
- 数字分析法:提取关键字中取值比较均匀的数字作为哈希地址
- 除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数作为哈希表地址。
- 分段叠加法:按照哈希表地址位数将关键字分成位数相等的几部分,其中最后一部分可以比较短。然后将这几部分相加,舍弃最高进位后的结果就是该关键字的哈希地址。
- 平方取中法:如果关键字各个部分分布都不均匀的话,可以先求出它的平方值,然后按照需求取中间的几位作为哈希地址。
- 伪随机数法:采用一个伪随机数当作哈希函数。
上面介绍过碰撞,衡量一个哈希函数的好坏的重要指标就是发生碰撞的概率以及发生碰撞的解决方案。任何哈希函数基本都无法彻底避免碰撞,常见的解决的方法有以下几种:
- 开放定址法:开放定址法就是一旦发生了冲突,就去寻找下一空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
- 链地址法:将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把关键字链在该单元为头结点的链表的尾部
- 再哈希法:当哈希地址发生冲突用其他的函数计算另外一个哈希函数地址,直到冲突不再产生为止。
- 建立公共溢出区:将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。
HashMap的数据结构
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易。上面我们提到过,常用的哈希函数的冲突解决办法中有一种方法叫做链地址法,其实就是将数组和链表组合在一起,发挥了两者的优势,我们可以理解为链表的数组。
它是由一个数组,数组的每一个成员是一个链表。该数据结构所容纳的所有元素均包换一个指针,用于元素间的链接。我们根据元素的自身特征把元素分配到不同的链表中去,反过来我们也是正通过这些特征找到正确的链表,再从链表中找出正确的元素。其中,根据元素特征计算元素数组下标的方法就是哈希算法,即本文的主角hash()函数。
hash方法
我们拿JDK1.7的HashMap为例,其中定义了一个final int hash(Object k)方法,其主要被以下方法引用。主要都是增加和删除方法,这不难理解,当我们要对一个链表数组中的某个元素进行增删的时候,首先要知道它应该保存在这个链表数组中的哪个位置,即它在这个数组中的下标。而hash()方法的功能就是根据Key来定位其在HashMap中的位置,HashTable、ConcurrentHashMap同理。
源码解析
首先,在同一版本的JDK中,HashMap、HashTable以及ConcurrentHashMap里面的hash方法的实现是不同的。在不同的版本的JDK中也是有区别的。
我们知道,hash方法的功能是根据Key来定位K-V在链表数组中的位置的。也就是hash方法的输入应该是个Object类型的Key,输出应该是一个int类型的数组下标。
我们只要调用Object对象的hashCode()方法,该方法会返回一个整数,然后用这个数对HashMap或者HashTable的容量进行取模就行了。在具体实现上,由两个方法int hash(Object k)和int indexFor(int h, int length)来实现。但是考虑到效率等问题,HashMap的实现会稍微复杂一点。
- hash:该方法主要将Object转换成一个整型
- indexFor:该方法主要将hash生成的整数型转换成链表数组的下标
HashMap In Java7
1 | //HashMap In Java7 |
indexFor方法其实主要是将hash生成的整型转换成链表数组中的下标。那么return h & (length - 1); 是什么意思呢?其实,它就是取模。Java之所以使用位运算(&)来代替取模运算(%),最主要的考虑就是效率。位运算效率要比取模运算高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。
那么,为什么可以使用位运算来实现取模运算呢?这实现的原理如下:
$X\%2^n = X\&(2^n -1)$,也就是说,一个数对$2^n$去模 == 一个数和$(2^n -1)$做按位与运算。
所以,return h & (length - 1);只要保证length的长度是$2^n$的话,就可以实现取模运算了。而HashMap中的length也确实是2的倍数,初始值是16,之后每次扩充为原来的2倍。
HashMap的数据是存储在链表数组里面的。在对HashMap进行插入/删除等操作时,都需要根据K-V对的键值定位到它应该保存在数组的那个下标中。而这个通过键值求取下标的操作就叫做哈希。HashMap的数组是有长度的,Java中规定这个长度只能是2的倍数,初始值为16。简单的做法是先求取出键值的hashcode,然后再将hashcode得到的int值对数组长度进行取模。为了考虑性能,Java总采用按位与操作实现取模操作。
接下来我们会发现,无论是用取模还是位运算都无法直接解决冲突较大的问题。例如:CA110000和00010000在对00001111进行按位与运算后的值是相等的。两个不同的键值,在对数组长度进行按位与运算后得到的结果相同,这就不发生了冲突了吗。那么如何解决这种冲突呢?
1 | h ^= k.hashCode(); |
这段代码是为了对Key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。
举个例子来说,向一个HashMap中put一个K-V对,Key值为“qwe”,经过简单的获取hashcode后值为“1011000110101110011111010011011”,如果当前HashTable的大小为16,即在不进行扰动计算的情况下,它最终得到的index结果计算为11。由于15的二进制扩展到32位为“00000000000000000000000000001111”,所以,一个数字在和它进行与操作的时候,前28位无论是什么,计算结果都一样。
经过扰动计算之后,就可以很好的避免了冲突。其实,使用位运算代替取模运算,除了性能之外,还有一个好处就是很好的解决符数的问题。因为我们知道,hashcode结果是int类型,而int类型取值范围为$-2^{31}$~$2^{31}-1$;这里面包含了符数的,对于一个符数取模还是有些麻烦的。如果使用二进制的位运算的话就可以很好的避免这个问题。首先,不管hashcode的值是正数还是符数。length-1这个值一定是个正数。那么,它的二进制的第一位一定是0,这样两个数按位与运算之后,第一位一定是个0,也就是,得到的结果一定是个正数。
HashTable In Java7
接下来看一下,线程安全的HashTable是如何实现的,和HashMap有何不同。下面是Java7中HashTable的hash方法的实现。
1 | private int hash(Object k){ |
只是对k做了简单的hash,取了一下其hashCode。而HashTable中也没有indexOf方法,取而代之的是这段代码:int index = (hash & 0x7FFFFFFF) % tab.length;也就是说,HashMap和HashTable对于计算数组下标这件事,采用了两种方法。HashMap采用的是位运算,而HashTable采用的是直接取模。
为什么要把hash值和0x7FFFFFFF做一次按位与操作呢?主要是因为为了保证得到的index的第一位为0,也就是为了得到一个正数。
HashMap之所以不用取模的原因是为了提高效率。有人认为,因为HashTable是个线程安全的类,本来就慢,所以Java并没有考虑效率问题,直接使用取模算法了呢?但是其实并不完全是,Java这样设计还有一定的考虑在,虽然这样效率确实是会比HashMap慢一些。其实,HashTable采用简单的取模是有一定的考虑在的,就是HashTable的构造函数和扩容函数了。HashTable默认的初始值大小为11,之后每次扩充为原来的2n+1。也就是说,HashTable的链表数组的默认大小是一个素数、奇数。之后每次扩充结果也是奇数。由于HashTable会尽量使用素数、奇数作为容量的大小。当哈希表的大小为素数时,简单的取模哈希的结果会更加均匀。
总结
- HashMap默认的初始化大小16,之后每次扩充为原来的2倍。
- HashTable默认的初始化大小为11,之后每次扩充为原来的2n+1。
- 当哈希表的大小为素数时,简单的取模哈希的结果会更加均匀,所以单从这一点上看,HashTable的哈希表大小选择,似乎更高明。因为hash结果越分散效果越好。
- 在取模运算时,如果模数是2的幂,那么我们可以直接使用位运算来得到结果,效率要远远大于做除法。所以hash计算的效率上,又是HashMap更胜一筹。
- 但是,HashMap为了提高效率使用位运算代替哈希,这又,这又引入了哈希分布不均匀的问题,所以HashMap为了解决这个问题,又对hash算法做了一些改进,进行了扰动计算。
ConcurrentHashMap In Java7
1 | private int hash(Object k){ |
上面的ConcurrentHashMap的hash实现其实和HashMap如出一辙,都是通过位运算代替取模,然后对hashcode进行扰动。区别在于,ConcurrentHashMap使用了一种变种的Wang/Jenkins 哈希算法,其主要目的也是为了把高位和低位组合在一起,避免发生冲突。
HashMap In Java8
在Java8之前,HashMap和其他基于Map的类都是通过链地址法解决冲突,它们使用单向链表来存储相同的索引的元素。在最坏的情况下,这种方式将HashMap的get方法的性能从O(1)降低到了O(n)。为了解决在频繁冲突时hashmap性能降低的问题,Java8中使用平衡树来代替链表存储冲突的元素。这意味着我们可以将最坏情况下的性能从O(n)提高到O(logn)。
若恶意程序知道我们用的是Hash算法,则在纯链表情况下,它能发送大量请求导致哈希碰撞,然后不停的访问这些Key导致HashMap忙于进行线性查找,最终陷入瘫痪,即形成了拒绝服务攻击(Dos)。
关于Java8中的hash函数,原理和Java7中基本类似。Java8中这一步做了优化,只做一次16位右位移异或混合,而不是四次,但原理不变。
1 | static final int hash(Object key){ |
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = key.hashCode()) ^ (h >>> 16),主要从速度、功效、质量来考虑的。以上方法得到的int的hash值,然后再通过 h & (table.length - 1)来得到该对象在数据中保存的位置。
HashTable In Java8
在Java8的HashTable中,已经有hash方法了。但是哈希的操作还是在的,比如在put方法中就有实现:
1 | int hash = key.hashCode(); |
这里和Java7中的实现几乎无差别。
ConcurrentHashMap In Java8
Java8中的求hash的方法改为了spread。实现方法如下:
1 | static final int spread(int h){ |
Java8的ConcurrentHashMap同样是通过Key的哈希值与数组长度取模确定该Key在数组中的索引。同样为了避免不太好的Key的hashCode设计,它通过如下方法计算得到Key的最终哈希值。不同的是,Java8的ConcurrentHashMap作者认为引入红黑树后,即使哈希冲突比较严重,寻址效率也足够高,所以作者并未在哈希值的计算上做过多设计,只是将Key的hashCode值与其高16位做异或并保证最高位为0。
为什么HashMap的默认容量设置为16
集合是Java开发日常中经常会使用到的,而作为一种典型K-V结构的数据结构,HashMap对于Java开发者一定不陌生。在日常开发中,我们经常会像如下方式创建一个HashMap:
1 | Map<String, String> map = new HashMap<String, String>(); |
此时,我们并没有给HashMap指定容量,那么这时候一个新创建的HashMap的默认容量是多少呢?为什么呢?
什么是容量
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易。HashMap就是将数组和链表组合在一起,发挥两者的优势,我们可以理解为链表的数组。
在HashMap中,有两个比较容易混淆的关键字段:size和capacity,这其中capacity就是Map的容量,而size我们称之为Map中的元素个数。
1 | Map<String, String> map = new HashMap<String, String>(); |
上面我们定义了一个新的HashMap,并向其中put了一个元素,然后通过反射的方式打印capacity和size,其容量为16,已经存放的元素个数为1。
容量与哈希
容量就是HashMap中桶的个数,那么,当我们想要往一个HashMap中put一个元素的时候,需要通过一定的计算出应该把它放到哪个桶中,这个过程就叫做哈希(hash),对应的就是HashMap中的hash方法。
hash方法的功能就是根据Key来定位这个K-V在链表数组中的位置的。也就是hash方法的输入应该是个Object类型的Key,输出应该是个int类型的数组下标。我们只要调用Object对象的hashCode()方法,该方法会返回一个整数,然后用这个数对HashMap容量进行取模就行了,再考虑到效率的问题,HashMap的hash方法的实现还是有一定的复杂的。
hash的实现
具体的实现上,由两个方法int hash(Object k)和int indexFor(int h, int length)来实现。hash:该方法主要将Object转换成一个整型;indexFor:该方法主要是将hash生成的整型转换成链表数组中的下标。
先来看indexFor方法,在Java7中该实现的细节:
1 | static int indexFor(int h, int length){ |
indexFor方法其实主要将hashcode换成链表数组中的下标。其中的两个参数h表示元素的hashcode值,length值表示HashMap的容量。那么return h & (length - 1)是什么意思呢?其实,它就是取模。Java之所以使用位运算($)来代替取模运算(%),最主要考虑的就是效率。位运算(&)效率要比代替取模运算(%)高得多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。
那么,为什么可以使用位运算(&)来实现取模运算(%)呢?这实现的原理如下:
$X \% 2^n=X \& (2^{n}-1)$,从二进制角度来看,X/8相当于X>>3,把X右移3位,此时得到了X/8的商,而被移掉的部分(后三位),则是X%8,也就是余数。
之所以可以做等价代替,前提要求是HashMap的容量一定是$2^n$。那么既然是$2^n$ ,为什么一定要是16呢?为什么不是4、8或者32呢?应该是个经验值(Experience Value),既然设置一个默认的$2^n$作为初始值,那么就需要在效率和内存上做一个平衡。这个值既不能太小也不能太大。太小就有可能发生频繁扩容,影响效率;太大又浪费空间,不划算。故16作为了一个经验值被采用。
在JDK 8中,关于默认容量定义为:static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;//aka 16 ,其故意把16写成了1 << 4,就是提醒开发者,这个地方要2的幂。
那么,接下来分析一下,HashMap是如何保证其容量一定可以是$2^n$的呢?如果用户自己设置了的话又会是怎么样?HashMap在两个可能改变其容量的地方都做了兼容处理,分别指定容量初始化时及扩容时。
指定容量初始化
当我们通过HashMap(int initialCapacity)设置初始容量的时候,HashMap并不一定会直接采用我们传入的值,而是经过计算,得到一个新值,目的是要提高hash效率。(1->1、3->4、7->8、9->16)
在JDK1.7和JDK1.8中,HashMap初始化这个容量的时机不同。JDK1.8中,在调用HashMap的构造函数定义HashMap的时候,就会进行容量的设定;而在JDK1.7中,要等一次put操作时才进行这一步操作。
看一下JDK是如何找到传入的指定值大的第一个2的幂的:
1 | int n = cap - 1; |
上面的算法的目的就是:根据用户传入的容量值(代码中的cap),通过计算,得到第一个比他大的2的幂并返回。
在5->8、9->16、19->32、37->64这些例子中都是主要经过了两个阶段:
- ①5->7;②7->8
- ①9->15;②15->16
- ①37->63;②63->64
对应以上的代码中,Step1①为:
1 | n | = n >>> 1; |
对应以上的代码中,Step2②为:
1 | return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; |
对于Step2比较简单,就是做一下极限值的判断,然后把Step+1得到的数值+1;
对于Step1中,就是对二进制数依次右移,然后与原值取或,其目的是对于一个二进制,从第一个不为0的位开始,把后面的所有位设置成1。
但是还有一种特殊的情况套用以上公式不行,这些数字本身就是2的幂自身。例如数字4套用公式会得到8。
扩容
除了初始化的时候指定了HashMap的容量,在进行扩容的时候,其容量也可能会改变。HashMap有扩容机制,就是当达到了扩容条件时会进行扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过了临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor * capacity。loadFactor是装载因子,表示HashMap满的程度,默认值为0.75f,设置为0.75有个好处正好是3/4,而capacity又是2的幂。所以两个数的乘积都是整数。
对于一个默认的HashMap来说,默认情况下,当期size大于12(16*0.75)时就会出发扩容。下面是HashMap中扩容方法(resize)中的一段:
1 | if((newCap = oldCap << 1) < MAXIMUM_CAPCITY && oldCap >= DEFAULT_INITIAL_CAPACITY) |
从上面代码可以看出,扩容后的table大小变为原来的两倍,这一步执行之后,就会进行扩容后table的调整。可见,当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容,扩容成原容量的2倍,即从16扩容到32、64、128…
所以,通过保证初始化容量均为2的幂,并且扩容时也是扩容到之前容量的2倍,所以,保证了HashMap的容量永远都是2的幂。
总结
HashMap做为一种数据结构,元素在put的过程中需要进行hash计算,目的是计算出该元素存放在HashMap中的具体位置。hash运算过程其实就是对目标元素的Key进行hashCode,再对Map的容量进行取模,而JDK的工程师为了提升取模的效率,使用了位运算取代取模运算的效率,使用位运算代替了取模运算,这就要求Map的容量一定是2的幂。
而作为默认容量,太小和太大都不适合,所以16就作为一个比较合适的经验值被采用了。为了保证任何情况下Map的容量都是2的幂,HashMap在两个地方都做了限制:首先是,若用户制定了初始容量,那么HashMap会计算出比该数大的第一个2的幂作为初始容量;另外,在扩容的时候,也是进行成倍扩容的,即4->8,8->16。
为什么建议设置HashMap的初始容量,设置多少合适
要设置HashMap的初始容量
HashMap有扩容机制,就是当达到了扩容条件时会进行扩容;HashMap的扩容条件就是当HashMap中元素个数(size)超过了临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor * capacity。
所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap会发生多次扩容,而HashMap中的扩容机制决定了每次扩容都需要重建hash表,是非常影响性能的。所以,首先可以明确的是,我们建议开发者在创建HashMap的时候指定初始化容量,在《Java开发手册》中也是这么建议的。
HashMap初始化容量设置多少合适
有些人会自然想到,我准备塞多少个元素我就设置成多少呗,例如准备塞7个元素,那么就new HashMap(7)。这样的做法不仅不对,而且以上方式创建出来的Map的容量也不是7。
因为,当我们使用HashMap(int initialCapacity)来初始化容量的时候,HashMap并不会使用我们传进来的initialCapacity直接作为初始容量。JDK会默认帮我们计算一个相对合理的值当做初始容量,所谓合理的值就是找到第一个比用户传入的值打的2的幂。
也就是说,当我们new HashMap(7)创建HashMap的时候,JDK会计算,帮我们创建容量为8的Map;当我们new HashMap(9)创建HashMap的时候,JDK会计算,帮我们创建容量为16的Map。
但是,这个值看似合理,实际上并不尽然。因为HashMap在根据用户传入的capacity计算得到的默认容量,并没有考虑到loadFactor这个因素,只是简单机械的计算出一个大于这个数字的2的幂。
loadFactor是负载因子,当HashMap中的元素个数(size)超过threshold = loadFactor * capacity时,就会进行扩容。当我们设置的默认值是7,经过JDK处理之后,HashMap的容量会被设置成8,但是,这个HashMap在元素个数达到8*0.75 = 6的时候就会进行一次扩容,这明显是我们不希望见到的。
那么,到底设置成什么值较为合理呢?参考JDK8中putAll方法中的实现:
1 | return (int) ((float) expectedSize / 0.75F + 1.0F); |
比如我们计划向HashMap中放入7个元素的时候,我们通过expectedSize / 0.75F + 1.0F计算,7/0.75 + 1 = 10,10经过JDK处理之后,会被设置成16,这就大大的减少了扩容的几率。
当HashMap内部维护的哈希表的容量达到75%时,会触发rehash,而rehash的过程是比较耗费时间的。所以初始化容量要设置成expectedSize / 0.75F + 1.0F的话,可以有效的减少冲突也可以减少误差。
所以,我们可以认为,当我们明确知道HashMap中的个数的时候,把默认值色设置为expectedSize / 0.75F + 1.0F是一个在性能上相对好的选择,但是,同时也会牺牲些内存。
这个算法在guava中有实现,开发的时候,可以直接通过Maps类创建一个HashMap:
1 | Map<String, String> map = Maps.newHashMapWithExpectedSize(7); |
其代码实现如下:
1 | public static <K, V>HashMap<K, V> newHashMapWithExpectedSize(int expectedSize){ |
以上的操作是一种用内存换性能的做法,真正使用的时候,需要考虑到内存的影响。但,大多数情况下,我们认为内存是一种比较富裕的资源。但是话又说回来了,有些时候,我们到底要不要设置HashMap的初始值,这个值设置成多少,真的有那么大影响么?其实也不见得!
但是,大的性能优化,不就是一个一个的优化细节堆叠出来的么?
Java8中stream相关用法
在Java中,集合和数组是我们经常会用到的的数据结构,需要经常对他们做增、删、改、查、聚合、统计、过滤等操作。相比之下,关系型数据库中也同样有这些操作,但在Java8之前,集合和数组的处理并不是很便捷。
不过,这一问题在Java8中得到了改善,Java8API添加了一个新的抽象称为流Stream,可以让你以一种声明的方式处理数据。
Stream介绍
Stream使用一种类似用SQL语句从数据库查询数据的直观方式来提供一种对Java集合运算和表达的高阶抽象。Stream API可以极大提高Java程序员的生产力,让程序员写出高效率、干净、简洁的代码。
这种风格将要处理的元素集合看作一种流,流在管道中传输,并且可以在管道的节点上进行处理,比如筛选,排序,聚合等。
Stream有以下特性及优点:
- 无存储。Stream不是一种数据结构,它只是某种数据源的一个视图,数据源可以是一个数组,Java容器或I/O channel等。
- 为函数式编程而生。对于Stream的任何修改都不会修改背后的数据源,比如对Stream执行过滤操作并不会删除被过滤的元素,而是会产生一个不包含过滤元素的新Stream。
- 惰式执行。Stream上的操作并不会立即执行,只有等到用户真正需要结果的时候才会执行。
- 可消费性。Stream只能被“消费”一次,一旦遍历过就会失效,就像容器的迭代器那样,想要再次遍历必须重新生成。
对于流的处理,主要有三种关键性操作:分别是流的创建、中间操作(intermediate operation)以及最终操作(terminal operation)。
Stream的创建
在Java8中,可以有多种方式来创建流。
- 1.通过已有的集合来创建流
在Java8中,除了增加了很多Stream相关的类以外,还对集合类自身做了增强,在其中增加了Stream方法,可以将一个集合类转换成流。
1 | List<String> strings = Arrays.asList("wk", "qwe", "hello", "helloworld"); |
以上,通过一个已有的List创建一个流。除此之外,还有一个parallelStream方法,可以为集合创建一个并行流。这种通过集合创建出一个Stream的方式也是比较常用的一种方式。
- 通过Stream创建流
可以使用Stream类提供的方法,直接返回一个由指定元素组成的流。
1 | Stream<String> stream = Stream.of("wk", "qwe", "hello", "helloworld"); |
如以上代码,直接通过of方法,创建并返回一个Stream。
Stream中间操作
Stream有很多中间操作,多个中间操作可以连接起来形成一个流水线,每个中间操作就像流水线上的一个工人,每个工人都可以对流进行加工,加工后得到的结果还是一个流。
以下是常用的中间操作列表:
Stream Operation | Goal | Input |
---|---|---|
filter | Filter items according to a given predicate | Predicate |
map | Processes items and transforms | Function |
limit | Limit the results | int |
sorted | Sort items inside stream | Comparator |
distinct | Remove duplicate items according to equals method of the given type |
filter
filter方法用于通过设置的条件过滤出元素。以下代码片段使用filter方法过滤掉空字符串:
1 | List<String> strings = Arrays.asList("wk", "", "qwe", "w", "wkk"); |
map
map方法用于映射每个元素到对应的结果,以下代码片段使用map输出了元素对应的平方数:
1 | List<Integer> numbers = Arrays.asList(3, 2, 2, 3, 7, 3, 5); |
limit/skip
limit返回Stream的前面n个元素;skip则是扔掉前n个元素。以下代码片段使用limit方法保留4个元素:
1 | List<Integer> numbers = Arrays.asLsit(3, 2, 2, 3, 7, 3, 5); |
sorted
sorted方法用于对流进行排序。以下代码片段使用sorted方法进行排序:
1 | List<Integer> numbers = Arrays.asList(3, 2, 2, 3, 7, 3, 5); |
distinct
distinct主要用来去重,以下代码片段使用distinct对元素进行去重:
1 | List<Integer> numbers = Arrays.asList(3, 2, 2, 3, 7, 3, 5); |
接下来是,当一个Stream先后通过filter、map、sort、limit以及distinct处理后发生了什么
1 | List<String> strings = Arrays.asList("wkwkwk", "wkhelloworld", "wkwkwk", "helloworld","qew"); |
Stream最终操作
Stream的中间操作得到的结果还是一个Stream,那么如何把一个Stream转换成我们需要的类型呢?比如计算出流元素个数、将流装换成集合。这就需要最终操作(terminal operation)。
最终操作会消耗流,产生一个最终结果,也就是说,在最终操作之后,不能再次使用流,也不能在使用任何中间操作,否则将抛出异常:
1 | java.lang.IllegalStateException: Stream has a already been operated upon or closed |
常用的最终操作如下图:
STREAM OPERATION | GOAL | INPUT |
---|---|---|
forEach | For every item, outputs something | Consumer |
count | Counts current items | |
collect | Reduces the stream into a desired collection |
forEach
Stream提供了方法forEach来迭代流中的每个数据。以下代码片段使用forEach输出了10个随机数:
1 | Random random = new Random(); |
count
count用来统计流中的元素个数
1 | List<String> strings = Arrays.asList("qwe", "asd", "zxc", "rty"); |
collect
collect就是一个归约操作,可以接受各种做法作为参数,将流中的元素累积成一个汇总结果:
1 | List<String> strings = Arrays.asList("qwe", "qweasd", "qwezxc","hello","helloworld"); |
总结
本节介绍了Java8中的Stream的用途,优点等。还介绍了Stream的几种用法,分别是Stream创建、中间操作和最终操作。
Stream的创建有两种方式,分别是通过集合类的stream方法、通过Stream的of方法。
Stream的中间操作可以用来处理Stream,中间操作的输入和输出都是Stream,中间操作可以是过滤、转换、排序等。
Stream的最终操作可以将Stream转成其他形式,如计算出流中元素的个数、将流装转换成集合、以及元素的遍历。
Apache集合处理工具类的使用
Commons Collections增强了Java Collections Framework,他提供了几个功能,使收集处理变得容易,他提供了许多新的接口,实现和实用程序。Commons Collections的主要功能如下:
- Bag - Bag界面简化了每个对象具有多个副本的集合
- BidiMap - BidiMap接口提供双向映射,可用于使用键或键查找值。
- MapIterator - MapIterator接口提供简单而容易的迭代
- Transforming Decorators - 转换装饰器可以在将集合添加到集合时更改集合的每个对象
- Composite Collections - 在需要统一处理多个集合的情况下使用复合集合
- Ordered Map - 有序地图保留添加元素的顺序
- Ordered Set - 有序集保留了添加元素的顺序
- Reference map - 参考图允许在密切控制下对键/值进行垃圾收集
- Comparator implmentations - 可以使用许多Comparator实现
- Iterator implementations - 许多Iterator实现都可用
- Adapter Classes - 适配器类可用于将数组和枚举转换为集合
- Utilities - 实用程序可用于测试测试或创建集合的典型集合论属性,例如union,intersection。支持关闭
Commons Collections - Bag
Bag定义了一个集合,用于计算对象在集合中出现的次数。例如,Bag包含{a,a,b,c},则getCount(“a”)将返回2,而uniqueSet()将返回唯一值。
1 | import org.apache.commons.collections4.Bag; |
Commons Collections - BidiMap
使用双向映射,可以使用值查找键,并且可以使用键轻松查找值。
1 | import org.apache.commons.collections4.BidiMap; |
Commons Collections - Maplterator
JDK Map接口很难迭代,因为迭代要在EntrySet和KeySet对象上完成。Mapiterator提供了对Map的简单迭代。
1 | import org.apache.commons.collections4.IterableMap; |
Commons Collections - OrderedMap
OrderedMap是地图的新接口,用于保留添加元素的顺序。LinkedMap和ListOrderedMap是两个可用的实现。此接口支持Map的迭代器,并允许在Map中向前或向后迭代两个方向。
1 | import org.apache.commons.collections4.OrderedMap; |
Commons Collections - Ignore NULL
Apache Commons Collections库的CollectionUtils类为常见操作提供了各种实用方法,涵盖了广泛的用例。它有助于避免编写样板代码,这个库在JDK8之前非常有用,因为Java8的Stream API现在提供了类似的功能。
1 | import java.util.LinkedList; |
Merge & Sort
Apache Commons Collections库的CollectionUtils类为常见操作提供了各种实用方法,涵盖了广泛的用例。它有助于避免编写样板代码。这个在JDK8之前非常有用,因为Java8的Stream API 现在提供了类似的功能。
1 | import java.util.Arrays; |
安全检查(Safe Empty Checks)
Apache Commons Collections库的CollectionUtils类为常见操作提供了各种实用方法,涵盖了广泛的用例。它有助于避免编写样板代码,这个库在JDK8之前非常有用,因为Java8的Stream API现在提供了类似的功能。
1 | import java.util.List; |
Commons Collections - Inclusion
检查列表是否是另外一个列表的一部分:
1 | import java.util.Arrays; |
Commons Collections - Intersection
用于获取两个集合之间的公共对象:
1 | import java.util.Arrays; |
Commons Collections - Subtraction
通过从其他集合中减去一个集合的对象来获取新集合:
1 | import java.util.Arrays; |
Commons Collections - Union
用于获取两个集合的并集:
1 | import java.util.Arrays; |
Arrays.asList获得的List使用需要注意什么
- asList得到的只是一个Arrays的内部类,一个原来数据的视图List,因此如果对它进行增删操作会报错。
- 用ArrayList的构造器可以让其转变成真正的ArrayList
Collection如何迭代
Collection的迭代有很多种方式:
- 通过普通for循环迭代
- 通过增强for循环迭代
- 使用Iterator迭代
- 使用Stream迭代
1 | List<String> list = ImmutableList.of("qwe", "wk"); |
Enumeration和Iterator区别
- 函数接口不同
Enumeration只有2个函数接口。通过Enumeration,我们只能读取集合的数据,而不能对数据进行修改;Iterator只有3个函数接口。Iterator除了能读取集合的数据之外,也能进行数据的删除操作。
- Iterator支持fail-fast机制,而Enumeration不支持
Enumeration是JDK1.0添加的接口。使用到它的函数包括Vector、Hashtable等类,这些类都是JDK1.0中加入的,Enumeration存在的目的就是为它们提供遍历接口。Enumeration本身没有支持同步,而在Vector、Hashtable实现Enumeration时,添加了同步;而Iterator是JDK1.2才添加的接口,它为了HashMap、ArrayList等集合提供遍历接口。Iterator是支持fail-fast机制的:当多个线程对同一集合的内容进行操作时,就可能会产生fail-fast事件。
注意:Enumeration迭代器只能遍历Vector、Hashtable这种古老的集合,因此通常不要使用它,除非在某些极端情况下,不得不使用Enumeration,否则都应该选择Iterator迭代器。
fail-fast和fail-safe
什么是fail-fast
在系统设计中,快速失效系统一种可以立即报告任何可能表明故障的情况的系统。快速失效系统通常设计用于停止正常操作,而不是试图继续可能存在缺陷的过程。这种设计通常会在操作中的多个检查系统的状态,因此可以及早检测到任何故障。快速失败模块的职责是检测错误,然后让系统的下一最高级别处理错误。
其实,就是在做系统设计的时候先考虑异常情况,一旦发生异常,直接停止并上报。
举一个最简单的fail-fast的例子:
1 | public int divide(int divisor, int dividend){ |
上面的代码是一个对两个整数做除法的方法,在divide方法中,我们对被除数做了个简单的检查,如果其值为0,那么就直接抛出一个异常,并明确提示异常原因。这其实就是fail-fast理念的实际应用。
这样做的好处就是可以预先识别出一些错误情况,一方面可以避免执行复杂的其他代码,另外一方面,这种异常情况被识别之后也可以针对性的做一些单独处理。
在Java的集合类中运用了fail-fast机制进行设计,一旦使用不当,触发fail-fast机制设计的代码,就会发生非预期情况。
集合类中的fail-fast
我们通常说的Java中的fail-fast机制,默认指的是Java集合的一种错误检测机制。当多个线程对部分集合进行结构上的改变的操作时,有可能会产生fail-fast机制,这个时候就会抛出ConcurrentModificationException(CME)。
CME,当方法检测到对象的并发修改,但不允许这种修改时就抛出该异常。
很多时候正是因为代码中抛出CME,很多程序员就会困惑,明明自己的代码并没有在多线程环境中执行,为什么会抛出这种并发有关的异常呢?这种情况在什么情况下才会抛出呢?
异常复现
在Java中,如果在foreach循环里对某些集合元素进行元素的remove/add操作的时候,就会触发fail-fast机制,进而抛出CME。
1 | List<String> userNames = new ArrayList<String>(){ |
以上代码,使用增强for循环遍历元素,并尝试删除其中的wk字符串元素,运行以上代码,会抛出以下异常:
1 | Exception in thread "main" java.util.ConcurrentModificationException |
同样的,在增强for循环中使用add方法添加元素,结果也会抛出同样的异常。
在深入原理之前,我们先尝试把foreach进行解语法糖,看一下foreach具体如何实现的。使用jad工具,对编译后的class进行反编译,得到以下代码:
1 | public static void main(String[] args) { |
可以发现,foreach其实是依赖了while循环和Iterator实现的。
异常原理
通过以上代码的异常堆栈,我们可以跟踪到真正抛出异常的代码:
1 | java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909) |
该方法是在iterator.next()方法中调用的。我们看下该方法的实现:
1 | final void checkForComodification(){ |
如上,该方法中对ModCount和expectedModCount进行了比较,如果二者不相等,则抛出CME。那么,modCount和expectedModCount是什么?是什么原因导致它们的值不相等的呢?
modCount是ArrayList中的一个成员变量。它表示该集合实际被修改的次数。
1 | List<String> userNames = new ArrayList<String>(){ |
当使用以上代码初始化集合之后该变量就有了,初始值为0。expectedModCount是ArrayList中的一个内部类——Itr中的成员变量
1 | Iterator iterator = userNames.iterator(); |
以上代码,既可以得到一个Itr类,该类实现了Iterator接口。expectedModCount表示这个迭代器预期该集合被修改的次数,其值随着Itr被创建而初始化,只有通过迭代器对集合进行操作,该值才会改变。
那么,接着我们看下userNames.remove(userName);方法做了什么事情,为什么导致expectedModCount和modCount的值不一样。
我们可以发现,remove方法核心逻辑如下:
1 | private void fastRemove(int index){ |
可以看到,它只修改了modCount,并没有对expectedModCount做任何操作。
简单总结一下,之所以会抛出CME异常,是因为我们的代码中使用了增强for循环,而在增强for循环中,遍历集合是通过Iterator进行的,但是元素的add/remove却是直接使用的集合类自己的方法。这就导致iterator在遍历的时候,会发现有一个元素在自己不知不觉的情况下就被删除了/添加了,就会抛出一个异常,用来提示用户,可能发生了并发修改!
所以,在使用Java集合类的时候,如果发生了CME,优先考虑fail-fast有关的情况,实际上这里并没有真的发生并发,只是Iterator使用了fail-fast的保护机制,只要它发现有某一次修改是未经过自己进行的,那么就会抛出异常。
fail-safe
为了避免触发fail-fast机制,导致异常,我们可以使用Java中提供的一些采用了fail-safe机制的集合类。
这样的集合容器在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
java.util.concurrent包下的容器都是fail-safe的,可以在多线程下并发使用,并发修改。同时也可以在foreach中进行add/remove。
我们拿CopyOnWriteArrayList这个fail-safe的集合类来简单分析一下:
1 | public static void main(String[] args){ |
以上代码,使用CopyOnWriteArrayList代替了ArrayList,就不会发生异常。
fail-safe集合的所有对集合的修改都是先拷贝一份副本,然后在副本集合上进行的,并不是在直接对原集合进行修改。并且这些修改方法,如add/remove都是通过加锁来控制并发的。
所以,CopyOnWriteArrayList中的迭代器在迭代过程中不需要做fail-fast的并发检测。但是,基于拷贝内容的优点是避免了CME,但同样地,迭代器并不能访问到修改后的内容,如以下代码:
1 | public static void main(String[] args){ |
我们得到CopyOnWriteArrayList的Iterator之后,通过for循环直接删除原数组中的值,最后在结尾处输出Iterator,结果发现内容如下:
1 | [qwe, w] |
迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
Copy-On-Write
在了解了CopyOnWriteArrayList之后,可能会有这样的疑问:它的add/remove等方法都已经加锁了,还要copy一份再修改干嘛?同样是线程安全的集合,和Vector有什么区别呢?
Copy-On-Write简称COW,是一种用于程序设计中的优化策略。其基本思路是,从一开始大家都在共享同一个内容,当某个人要修改这个内容的时候,才会真正把内容Copy出去形成一个新的内容然后再改,这是一种延时懒惰策略。
CopyOnWirte容器即写时复制的容器。通俗理解是当我们往一个容器添加元素的时候,不能直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
CopyOnWriteArrayList中的add/remove等写方法是需要加锁的,目的是为了避免Copy出N个副本出来,导致并发写。
但是,CopyOnWriteArrayList中的读方法是没有加锁的。
1 | public E get(int index){ |
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,当然,这里读到的数据可能不是最新的。因为写时复制的思想是通过延时更新的策略来实现数据的最终一致性的,并非强一致性。
所以CopyOnWrite容器是一种读写分离的思想,读和写不同的容器。而Vector在读写的时候使用同一个容器,读写互斥,同时只能做一件事儿。
如何在遍历的同时删除ArrayList中的元素
直接使用普通for循环进行操作
我们说不能再foreach中进行,但是使用普通的for循环还是可以的,因为普通的for循环并没有用到Iterator的遍历,所以压根就没有fail-fast的检验。
1 | List<String> userNames = new ArrayList<String>(){ |
这种方案其实存在一个问题,那就是remove操作会改变List中元素的下标,可能存在漏删的情况。
直接使用Iterator进行操作
除了直接使用普通的for循环以外,我们还可以直接使用Iterator提供remove方法。
1 | List<String> userNames = new ArrayList<String>(){ |
如果直接使用Iterator提供的remove方法,那么就可以修改到expectedModCount的值。那么就不再抛出异常了。
使用Java 8 中提供的filter过滤
Java 8 中可以把集合转化成流,对于流有一种filter操作,可以对原始Stream进行某项测试,通过测试的元素被留下生成一个新的Stream。
1 | List<String> userNames = new ArrayList<String>(){ |
使用增强for循环其实也可以
如果,我们非常确定在一个集合中,某个即将删除的元素只包含一个的话,比如对Set进行操作,那么其实也是可以使用增强for循环的,只要在删除之后,立刻结束循环体,不要再继续进行遍历就可以了,也就是说不让代码执行到下一次的next方法。
1 | List<String> userNames = new ArrayList<String>(){ |
直接使用fail-safe的集合类
在Java中,除了一些普通的集合类以外,还有一些采用了fail-safe机制的集合类。这样的集合容器在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所做的修改并不能被迭代器检测到,所以不会触发CME。
1 | ConcurrentLinkedDeque<String> userNames = new ConcurrentLinkedDeque<String>(){ |
基本拷贝内容的优点是避免了CME,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道。
java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
CopyOnWriteArrayList
Copy-On-Write简称COW,是一种用于程序设计中的优化策略。其基本思路是,从一开始大家都在共享同一个内容,当某个人想要修改这个内容的时候,才会真正把内容Copy出去形成一个新的内容然后再改,这是一种延时惰性策略。从JDK1.5开始Java并发包中提供了两个使用CopyOnWrite机制实现的并发容器,它们是CopyOnWriteArrayList和CopyOnWriteArraySet。CopyOnWrite容器非常有用,可以在非常多的并发场景中使用到。
CopyOnWriteArrayList相当于线程安全的ArrayList,CopyOnWriteArrayList使用了一种叫写时复制的方法,当有新的元素add到CopyOnWriteArrayList时,先从原有的数组中拷贝一份出来,然后在新的数组做写操作,写完之后,再将原来的数组引用指向到新的数组。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
注意:CopyOnWriteArrayList的整个add操作都是在锁的保护下进行的,也就是说add方法是线程安全的。
CopyOnWrite并发容器用于读多写少的并发场景。比如白名单,黑名单,商品类目的访问和跟新场景。
和ArrayList不同的是,它具有以下特性:
支持高效率并发且是线程安全的,因为通常需要复制整个基础数组,所以可变操作(add()、set()、remove()等等)的开销很大,迭代器支持hasNext()、next()等不可变操作,但不支持可变remove()等操作,使用迭代器进行遍历的速度很快,并且不会与其他线程发生冲突。在构造迭代器时,迭代器依赖于不变的数组快照。
ConcurrentSkipListMap
ConcurrentSkipListMap是一个内部使用跳表,并支持排序和并发的一个Map,是线程安全的。一般很少会用到,也是一个比较偏门的数据结构。
简单介绍下跳表:
跳表是一种允许在一个有顺序的序列中进行快速查询的数据结构。在普通的顺序链表中查询一个元素,需要从链表头部开始一个一个节点进行遍历,然后找到节点。跳表可以解决这种查询时间过长,跳表是一种“空间换时间”的概念用来提高查询效率的链表。
ConcurrentSkipListMap和ConcurrentHashMap的主要区别:1.底层实现方式不同,ConcurrentSkipListMap底层基于跳表,ConcurrentHashMap底层基于Hash桶和红黑树;2.ConcurrentHashMap不支持排序,ConcurrentSkipListMap支持排序。
I/O流
字符流、字节流
字节与字符
Bit最小的二进制单位,是计算机的操作部分。取值0或1
Byte(字节)是计算机操作数据的最小单位由8位bit组成取值(-128~127)
Char(字符)是用户的可读写的最小单位,在Java里面由16位bit组成取值(0~65535)
字节流
操作byte类型数据,主要操作类是OutputStream、InputStream的子类;不用缓冲区,直接对文件本身操作。
字符流
操作字符类型数据,主要操作类是Reader、Write的子类;使用缓冲区缓冲字符,不关闭流就不会输出任何内容。
互相转换
整个IO包实际上分为字节流和字符流,但是除了这两个流之外,还存在一组字节流-字符流的转换类。
OutputStreamWrite:是Write的子类,将输出的字符流变为字节流,即将一个字符流的输出对象变为字节流输出对象。
InputStreamReader:是Reader的子类,将输入的字节流变为字符流,即将一个字节流的输出对象变为字符流的输出对象。
输入流、输出流
输入、输出,有一个参照物,参照物就是存储数据的介质。如果是把对象读入到介质中,这就是输入。从介质中向外读数据,这就是输出。
所以,输入流把数据写入存储介质的;输出流是从存储介质中把数据读取出来。
字节流和字符流之间的相互转换
想要实现字符流和字节流之间的相互转换需要用到两个类:
- OutputStreamWrite是字符流通向字节流的桥梁
- InputStreamReader是字节流通向字符流的桥梁
字符流转换成字节流
1 | public static void main(String[] args) throws IOException{ |
字节流转换成字符流
1 | public static void main(String[] args) throws IOException{ |
同步、异步
同步与异步描述的是被调用者的。如A调用B:如果是同步,B在接到A的调用后,会立即执行要做的事。A的本次调用可以得到结果。
如果是异步,B在接到A的调用后,不保证会立刻执行要做的事,但是保证会去做,B在做好了之后会通知A。A的本次调用得不到结果,但是B执行完之后会通知A。
阻塞、非阻塞
阻塞与非阻塞描述的是调用者。如A调用B:如果是阻塞,A在发出调用后,要一直等待,等着B返回结果。
如果是非阻塞,A在发出调用后,不需要等待,可以去做自己的事情。
同步,异步胡阻塞,非阻塞之间的区别
- 同步和异步是描述被调用方的
- 阻塞和非阻塞是描述调用方的
- 同步不一定阻塞,异步不一定非阻塞,没有必然关系。
Linux 5种IO模型
阻塞式IO模型
最传统的一种IO模型,即在读写数据过程中会发生阻塞现象。
当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block状态。
典型阻塞IO模型的例子:
1 | data = socket.read(); |
如果数据没有就绪,就会一直阻塞在read方法中。
非阻塞IO模型
当用户线程发起一个read操作之后,并不需要等待,而是马上得到一个结果。如果结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。
在非阻塞IO中,用户线程需要不断地询问内核数据是否就绪,也就是说非阻塞IO不会交出CPU,而会一直占用CPU。
典型的非阻塞IO模型一般如下:
1 | while(true){ |
但是对于非阻塞IO就有一个非常严重的问题,在while循环中需要不断地去询问内核数据是否就绪,这样就会导致CPU占用率非常高,因此一般情况下很少使用while循环这种方式来读取数据。
IO复用模型
多路复用IO模型是目前使用的比较多的模型。Java NIO实际上就是多路复用IO。
在多路复用IO模型中,会有一个线程不断去轮询多个socket状态,只有当socket真正有读写事件时,才会正真调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有socket读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。
在Java NIO中,是通过selector.select()去查询每个通道是否有到达事件,如果没有事件,则一直阻塞在那里,因此这种方式会导致用户线程的阻塞。
也许有些人会说,我们可以采用多线程+阻塞IO达到类似的效果,但是由于在多线程+ 阻塞IO中,每个socket对应一个线程,这样会造成很大的资源占用,并且尤其是对于长连接来说,线程的资源一直不释放,如果后面陆续有很多连接的话,就会造成性能上的瓶颈。
而多路复用IO模式,通过一个线程就可以管理多个socket,只有当socket真正有读写事件发生才会占用资源来进行实际的读写操作。因此,多路复用IO比较适合连接数比较多的情况。
多路复用IO为何比非阻塞IO模型的效率要高是因为在非阻塞IO中,不断地询问socket状态时通过用户线程去进行的,而在多路复用IO中,轮询每个socket状态是内核在进行的,这个效率要比用户线程要高的多。
要注意的是,多路复用IO模型是通过轮询的方式来检测是否有事件到达,并且对到达的事件逐一进行响应。因此对于多路复用IO模型来说,一旦事情相应体很大,那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询。
信号驱动IO模型
在信号驱动IO模型中,当用户线程发起一个IO请求操作,会给对应的socket注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号之后,便在信号函数中调用IO读写操作来进行实际的IO请求操作。
异步IO模型
异步IO模型是比较理想的IO模型,在异步IO模型中,当用户线程发起read操作之后,立刻就可以开始去做其他的事。而另一方面,从内核的角度,当它受到一个asynchronous read之后,它会立刻返回,说明read请求已经成功发起了,因此不会对用户线程产生任何bolck。然后,内核会等待数据准备完成,然后将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它read操作完成了。也就是说用户线程完全不需要实际的整个IO操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时IO操作已经完成,可以直接去使用数据了。
在异步IO模型中,IO操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成,然后发送一个信号告知用户线程操作已经完成。用户线程中不需要再次调用IO函数进行具体的读写,这点和信号驱动模型有所不同,在信号模型驱动中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用IO函数进行实际的读写操作;而在异步IO模型中,收到信号表示IO操作已经完成,不需要再在用户线程中调用IO函数进行实际的读写操作。
注意,异步IO是需要操作系统的底层支持的,在Java7中,提供了Asynchronous IO。
前面四种IO模型实际上都属于同步IO,只有最后一种真正的异步IO,因为无论是多路复用IO还是信号驱动模型,IO操作的第2个阶段都会引起用户线程阻塞,也就是内核进行数据拷贝的过程都会让用户线程阻塞。
BIO、NIO和AIO的区别、三种IO的用法与原理
IO
什么是IO?它是指计算机与外部世界或者一个程序与计算机的其余部分的之间的接口。它对于任何计算机系统都非常关键,因而所有I/O的主体实际上是内置在操作系统中的。单独的程序一般是让系统为它们完成大部分的工作。
在Java编程中,直到最近一直使用流的方式完成I/O。所有I/O都被视为单个的字节的移动,通过一个stream的对象一次移动一个字节。流I/O用于和外部世界接触。它也在内部使用,用于对象转换为字节,然后再转换为对象。
BIO
Java BIO即Block I/O,同步并阻塞的IO。BIO就是传统的java.io包下面的代码实现。
NIO
什么是NIO?NIO与原来的I/O有同样的作用和目的,它们之间最重要的区别是数据打包和传输的方式。原来I/O以流的方式处理数据,而NIO以块的方式处理数据。
面向流的I/O系统一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易。链接几个过滤器,以便每个过滤器只负责单个复杂处理机制的一部分,这样也是相对简单的。不利的一面是,面向流的I/O通常非常慢。
一个面向块的I/O系统以块的形式处理数据。每一个操作都在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快的多。但是面向块的I/O缺少一些面向流的I/O所具有的优雅性和简单性。
AIO
Java AIO即Async非阻塞,是异步非阻塞的IO。
区别及联系
BIO(Blocking I/O):同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。
NIO(New I/O):同时支持阻塞与非阻塞模式。
AIO(Asynchronous I/O):异步非阻塞I/O模型。异步非阻塞与同步非阻塞区别在哪里?异步非阻塞无需一个线程去轮询所有IO操作的状态改变,在相应的状态改变后,系统会通知对用的线程来处理。
各自适用场景
BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。
NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4开始支持。
AIO方式适用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。
使用方式
- 使用BIO实现文件的读取与写入
1 | //Initializes the Object |
- 使用NIO实现文件的读取和写入
1 | static void readNIO(){ |
- 使用AIO实现文件的读取和写入
1 | public class ReadFromFile{ |
Netty
Netty是一种非阻塞I/O客户端-服务端框架,主要用于开发Java网络应用程序,如协议服务器和客户端。异步事件驱动的网络应用程序框架和工具用于简化网络编程,例如TCP和UDP套接字服务器。Netty包括了反应器编程模式的实现。Netty最初由JBoss开发,现在由Netty项目社区开发和维护。
除了作为异步网络应用框架,Netty还包括了对HTTP、HTTP2、DNS及其他协议的支持,涵盖了在Servlet容器内运行的能力、对WebSockets的支持、与Google Protocol Buffers的集成,对SSL/TLS的支持以及对应用于SPDY协议和消息压缩的支持。自2004年以来,Netty一直在被积极的开发。
从版本4.0.0开始,Netty在支持NIO和阻塞Java套接字的同时,还支持使用NIO.2作为后端。
本质:JBoss做的一个Jar包
目的:快速开发高性能、高可靠性的网络服务器和客户端程序
有点:提供异步的、事件驱动的网络应用程序框架和工具
反射
反射
反射机制指的是程序在运行时能获取自身的信息。在Java中,只要给定类的名字,那么就可以通过反射机制来获得类的所有属性和方法。
反射有什么作用
- 在运行时判断任意一个对象所属的类
- 在运行时判断任意一个类所有具有的成员变量和方法
- 在运行时任意调用一个对象的方法
- 在运行时构造任意一个类的对象
Class类
Java的Class类是Java反射机制的基础,通过Class类我们可以获得关于一个类的相关信息。
Java.lang.Class是一个比较特殊的类,它用于封装被装入到JVM中的类(包括类和接口)的信息。当一个类或接口被装入JVM时便会产生一个与之关联的java.lang.Class对象,可以通过这个Class对象对被装入类的详细信息进行访问。
虚拟机为每种类型管理一个独一无二的Class对象。也就是说,每个类都有一个Class对象。运行程序时,Java虚拟机(JVM)首先检查是否所要加载的类对应的Class对象是否已经加载。如果没有加载,JVM就会根据类名查找.class文件,并将其Class对象载入。
反射与工厂模式实现Spring IOC
反射机制概念
我们考虑一个场景,如果我们在程序运行时,一个对象想要检视自己所拥有的成员属性,该如何操作?再考虑一个场景,如果我们想要在运行期获得某个类的Class信息如它的属性,构造方法、一般方法后再考虑是否创建它的对象,这种情况该怎么办?这就需要用到反射。
我们.java文件在编译之后会变成.class文件,这就像个镜面,本身是.java,在镜中是.class,他们其实是一样的;那么同理,我们看到镜子的反射是.class,就能通过反编译,了解到.java文件的本来面目。
对于反射,官方给出的概念是:反射是Java语言的一个特性,它允许程序在运行时来进行自我检查并且对内部的成员进行操作。例如它允许一个Java类获取它所有的成员变量和方法并且显示出来。
反射主要是指程序可以访问,检测和修改它本身状态的一种能力,并能根据自身行为的状态和结果,调整或修改应用所描述行为的状态和相关语义。在Java中,只要给定类的名字,那么就可以通过反射机制来获得类的所有信息。
反射是Java中的一种强大工具,能够使我们很方便的创建灵活的代码,这些代码可以在运行时装配,无需在组件之间进行源代码链接。但是反射使用不当会成本很高!类中有什么信息,利用反射机制就能可以获得什么信息,不过前提是得知道类得名字。
反射机制的作用
* 在运行时判断任意一个对象所属的类
* 在运行时获取类的对象
* 在运行时访问java对象的属性,方法,构造方法等
首先要搞清楚为什么要用反射机制?直接创建对象不就可以了么?这就涉及到了动态与静态的概念。
静态编译:在编译时确定类型,绑定对象,即通过。
动态编译:运行时确定类型,绑定对象。动态编译最大限度发挥了Java的灵活性,体现了多态的应用,有效降低了类之间的耦合性。
反射机制的优缺点
反射机制的优点:可以实现动态创建对象和编译,体现出很大的灵活性。通过反射机制我们可以获得类的各种内容,进行反编译。对于JAVA这种先编译再运行的语言来说,反射机制可以使代码更加灵活,更加容易实现面向对象。
例如,一个大型的软件,不可能一次就把它设计的很完美,把这个程序编译后,发布了,当发现需要跟新某些功能时,我们不可能要用户把以前的卸载掉,再重新安装新的版本,加入这样的话,这个软件肯定是没有多少人用的。采用静态的话,需要把整个程序重新编译一次才可以实现功能的更新,而采用反射机制的话,它就可以不用卸载,只需要在运行时动态地创建和编译,就可以实现该功能。
反射机制的缺点:对性能有影响。使用反射机制基本上是一种解释操作,我们可以告诉JVM,我们希望做什么并且让它满足我们的要求,这类操作总是慢于直接执行相同的操作。
反射与工厂模式实现IOC
Spring中的IOC的实现原理就是工厂模式加反射机制。首先我们先来看一下不用反射机制时的工厂模式:
1 | interface fruit{ |
上面写法的缺点是当我们再添加一个子类的时候,就需要修改工厂类了。如果我们添加了太多的子类的时候,改动就会很多。下面用反射机制实现工厂模式:
1 | interface fruit{ |
现在就算我们添加任意多个子类的时候,工厂类都不需要修改。使用反射机制实现的工厂模式可以通过反射取得接口的实例,但是需要传入完整的包和类名。而用户也无法知道一个接口有多少个可以使用的子类,所以我们要通过属性文件的形式配置所需要的子类。
下面编写使用反射机制并结合属性文件的工厂模式即IOC。首先创建一个fruit.properties的资源文件:
1 | apple=Reflect.Apple |
然后编写主类代码:
1 | interface fruit{ |
IOC容器的技术剖析
IOC中最基本的技术就是“反射(Reflection)”编程,通俗的来讲就是根据具体的类名来动态地生成对象,这种编程方式可以让对象在生成时才被决定到底是哪一种对象。只是在Spring中要产生的对象都在配置文件中给出定义,目的就是提高灵活性和可维护性。
目前C#、Java和PHP5等语言都支持反射,其中PHP5的技术书籍中,有时候也被翻译成“映射”。反射的应用是很广泛的,很多成熟的框架,比如像Java中的Hibernate、Spring框架,.NET中NHibernate、Spring.NET框架都把“反射”作为最基本的技术手段。
反射技术其实很早就出现了,但一直被忽略,没有被进一步的利用。当时的反射编程方式相对于正常的对象生成方式要慢至少10倍。现在的反射技术经过改良优化,已经非常成熟,反射方式生成对象和通常对象生成方式,速度已经相差不大了,大约1-2倍的差距。
我们可以将IOC容器的工作模式看作为工厂模式的升华,可以把IOC容器看作一个工厂,这个工厂里产生的对象都有配置文件中给出定义,然后利用编程语言提供的反射机制,根据配置文件中给出的类名生成相应的对象。从实现来看,IOC是把以前在工厂方法里写死的对象生成代码,改变为由配置文件来定义,也就是把工厂和对象生成的两者独立分隔开来,目的就是提高灵活性和可维护性。
使用IOC框架应该注意什么
使用IOC框架产品能够给我们的开发过程带来很大好处,但是也要充分认识引入IOC框架的缺点,做到心中有数,杜绝滥用框架。
- 软件系统中由于引入了第三方IOC容器,生成对象的步骤变得有些复杂,本来是两者之间的事情,又凭空多出一道手续,所以,我们在刚开始IOC框架的时候,会感觉系统变得不太直观。所以,引入了一个全新的框架,就会增加团队成员学习和认识的培训成本,并且在以后的运行维护中,还得让新加入者具备同样的知识体系。
- 由于IOC容器生成对象是通过反射方式,在运行效率上有一定的耗损。如果你要追求运行效率的话,就必须对此权衡。
- 具体到IOC框架产品(例如Spring)而言,需要进行大量的配置工作,比较繁琐,对于一些小的项目而言,客观上也可能加大一些工作成本。
- IOC框架产品本身的成熟度需要进行评估,如果引入一个不成熟的IOC框架产品,那么会影响到整个项目,所以这也是一个隐形的风险。
我们大体可以得出这样的结论:一些工作量不大的项目或产品,不太适合使用IOC框架产品。另外,如果团队成员的知识能力欠缺,对于IOC框架产品缺乏深入理解,也不要贸然引入。最后,特别强调运行效率的项目或者产品,也不太适合引入IOC框架产品,像WEB2.0网站就是这种情况。
枚举类型和泛型
枚举的用法
背景
在Java语言中还没有引入枚举类型之前,表示枚举类型的常用模式是声明一组具int 常量。之前我们通常利用public static final 方法定义的代码如下:
1 | public class Season{ |
这种方法称为int枚举模式。可这种模式会出现什么问题呢?通常我们写出来的代码都会考虑它的安全性、易读性和可读性。首先我们来考虑一下它的安全性当然这种模式不是类型安全的。比如我们要设计一个函数,要求传入上面的某个值。但是使用int类型,我们无法保证传入的值为合法。代码如下:
1 | private String getChineseSeason(int season){ |
程序getChineseSeason(Season.SPRING)是我们预期的使用方法,可是getChineseSeason(5)显然就不是了,而且编译会很通过,在运行时会出现什么情况,我们就不得而知了。这显然就不符合Java程序的类型安全。
接下来我们考虑一下这种模式的可读性。使用枚举的大多数场合,我们都需要方便得到枚举类型的字符串表达式。如果将int枚举常量打印出来,我们所见到的就是一组数字,这是没什么太大用处的。我们可能会想到使用String常量代替int常量。虽然它认为这些常量可以提供了打印的字符串,但他可能会导致性能问题,因为它依赖于字符串的比较操作,所以这种模式也是我们不期望的。从类型安全和程序的可读性两方面考虑,int和String枚举模式的缺点就显露出来了。幸运的是,从Java1.5开始,就提出了另一种可以替代的解决方案,可以避免int和String枚举模式的缺点,并提供了许多额外的好处。那就是枚举类型(enum type)。
定义
枚举类型(enum type)是指由一组固定的常量组成合法的类型。Java中由关键字enum来定义一个枚举类型。下面就是Java枚举类型的定义。
1 | public enum Season{ |
特点
Java定义枚举类型的语句很简约。它有以下特点:
- 使用关键字enum
- 类型名称,例如这里的Season
- 一串允许的值,例如上述定义的春夏秋冬
- 枚举可以单独定义在一个文件中,也可以嵌在其他Java类中除了这样的基本要求外,用户还有一些其他选择
- 枚举可以实现一个或多个接口(Interface)
- 可以定义新的变量
- 可以定义新方法
- 可以定义根据具体枚举值而相异的类
应用场景
在以背景中提到的类型安全为例,用枚举类型重写那段代码。
1 | public enum Season{ |
其中有一个问题,为什么我们要将域添加到枚举类型中呢?目的是想将数据与它的常量关联起来。如1代表春天;2代表夏天。
总结
什么时候该使用枚举呢?每当需要一组固定的常量的时候,如一周的天数、一年四季。或者是在我么并编译前就知道其包含的所有值得集合。Java1.5的枚举能够满足绝大部分程序员的要求,它的简明,易用的特点是很突出的。
用法
用法一:常量
1 | public enum Color{ |
用法二:
1 | enum Signal{ |
用法三:向枚举中添加新方法
1 | public enum Color{ |
用法四:覆盖枚举的方法
1 | public enum Color{ |
用法五:实现接口
1 | public interface Behaviour{ |
用法六:使用接口组织枚举
1 | public interface Food{ |
枚举的实现
Java5提供了一种新的类型-Java的枚举类型,关键字enum可以将一组具名的值的有限集合创建为一种新的类型,而这些具名的值可以作为常规的程序组件使用,这是一种非常有用的功能。
那么枚举类到底是什么类呢?是enum?答案很明显不是,enum就和class一样,只是一个关键字,它并不是一个类,那么枚举是由什么类维护的呢?
1 | public enum t{ |
然后使用反编译,看看这段代码是怎么实现的,反编译后代码内容如下:
1 | public final class T extends Enum{ |
通过反编译后代码我们可以看到,public final class T extends Enum, 说明,该类是继承了Enum类的,同时final关键字告诉我们,这个类是不能被继承的。
当我们使用enum来定义一个枚举类型的时候,编译器会自动帮我们创建一个final类型的类继承Enum类,所以枚举类型不能被继承。
枚举与单例
我们知道,单例模式,一般有七种写法,那么这七种写法中,最好的是哪一种?为什么呢?
哪种写单例的方式最好
使用枚举实现单例的方法虽然还没有广泛的采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法。
枚举单例写法简单
各种方式实现单例的代码都比较复杂。主要原因是在考虑线程安全问题。我们简单对比下“双重检验锁”方式和枚举方式实现单例的代码。
“双重检验锁”实现单例:
1 | public class Singleton{ |
枚举实现单例:
1 | public enum Singleton{ |
相比之下,枚举实现单例的代码会精简很多。
上面的双重锁校验的代码很是臃肿,是因为大部分代码都是在保证线程安全。为了在保证线程安全和锁的颗粒度之间做权衡;但是,这段代码还是有问题的,因为它无法解决反序列化会破化单例的问题。
枚举可解决线程安全问题
上面提到过,使用非枚举的方式实现单例,都要自己来保证线程安全,所以,这就导致其他方法必然是计较臃肿的。那么,为什么使用枚举就不需要解决线程安全问题呢?
其实,并不是使用枚举就不需要保证线程安全,只不过线程安全的保证不需要我们关心而已。也就是说,其实在“底层”还是做了线程安全方面的保证的。
那么,“底层”到底指的是什么?
枚举类中的各个枚举项是通过static来定义的:举例见
static类型的属性会在类被加载之后初始化,当一个Java类第一次被真正使用的时候静态资源被初始化、Java类的加载和初始化过程都是线程安全的(因为虚拟机在加载枚举的类的时候,会使用ClassLoader的loadClass方法,而这个方法使用同步代码块保证了线程安全)。所以,创建一个enum类型是线程安全的。
也就是说,我们的定义的一个枚举,在第一次被真正用到的时候,会被虚拟机加载并初始化,而这个初始化过程是线程安全的。而我们知道,解决单例的并发问题,主要解决的就是初始化过程中的线程安全问题。
所以,由于枚举的以上特性,枚举实现的单例是天生线程安全的。
枚举可解决反序列化会破坏单例的问题
前面提到过,就是使用双重检验锁实现的单例其实是存在一定的问题的,就是这种单例有可能被序列化锁破坏。那么对于序列化这件事情,为什么枚举又有无先天的优势了呢?
在序列化的时候Java仅仅是将枚举类对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。
普通的Java类的反序列化过程中,会通过反射调用类的默认构造函数来初始化对象。所以,即使单例中构造函数是私有的,也会被反射给破坏掉。由于反序列化后的对象是重新new出来的,所以就破坏了单例。
但是,枚举的反序列化并不是通过反射实现的。所以,就不会发生由于反序列化导致的单例破坏问题。
总结
在所有的单例实现方式中,枚举是一种在代码写法上最简单的方式,之所以代码十分简洁,是因为Java给我们提供了enum关键字,我们便可以很方便的声明一个枚举类型,而不需要关心其初始化过程中的线程安全问题,因为枚举类在被虚拟机加载的时候会保证线程安全的被初始化。
初次之外,在序列化方面,Java中有明确规定,枚举的序列化和反序列化是有特殊定制的。这就可以避免反序列化过程中由于反射而导致的单例被破坏问题。
Enum类
Java中定义枚举是使用Enum关键字的,但是Java中其实还有一个java.lang.Enum类。这是一个抽象类,定义如下
1 | paekage java.lang; |
这个类我们在日常开发中不会用到,但是其实我们使用enum定义的枚举,其实现方式就是通过继承Enum类实现的。
当我们使用enum来定义一个枚举类型的时候,编译器会自动帮我们创建一个final类型的类继承Enum类,所以枚举类不能被继承。
Java枚举如何比较
Java枚举值比较用==和equals方法没什么区别,两个随便用都是一样的效果。因为枚举Enum类的equals方法默认实现就是通过\==来比较的;类似的Enum的CompareTo方法比较的是Enum的ordinal顺序大小;类似的还有Enum的name方法和toString方法一样都返回的是Enum和name值。
switch对枚举类的支持
Java1.7之前switch参数可用类行为short、byte、int、char、枚举类之所以能使用其实是编译器层面实现的。
编译器会将枚举switch转换为类似:
1 | switch(s.ordinal()){ |
所以实质还是int参数类型。
枚举的序列化如何实现
关键字enum可以将一组具名的值的有限集合创建为一种新的类型,而这些具名的值可以作为常规的程序组件使用,这是一种非常有用的功能。
枚举是如何保证线程安全的
为什么用枚举实现的单例是最好的方式
- 枚举写法简单
1 | public enum EasySingleton{ |
你可以通过EasySingleton.INSTANCE来访问。
- 枚举自己处理序列化
我们知道,以前所有的单例模式都有一个比较大的问题,就是一旦实现了Serializable接口之后,就不再是单例得了,因为,每次调用readObject()方法返回都是一个新创建出来的对象,有一种解决办法就是使用readResolve()来避免此事发生。但是,为了保证枚举类型像Java规范中说的那样,每一个枚举类型及其定义得枚举变量在JVM中都是唯一的,在枚举类型的序列化和反序列化上,Java做了特殊的规定。
在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。
看一下valueOf方法:
1 | public static <T extends Enum<T>> T valueOf(Class<T> enumType, String name){ |
从代码中可以看到,代码会尝试从调用enumType这个Class对象的enumConstantDirectory()方法返回的map中获取名字为name的枚举对象,如果不存在就会抛出异常。再一步跟到enumConstantDirectory()方法,就会发现到最后会以反射的方式调用enumType这个类型的values()静态方法,也就是上面我们看到的编译器为我们创建的那个方法,然后用返回结果填充enumType这个Class对象中的enumConstantDirectory属性。
所以,JVM对序列化有保证。
- 枚举实例创建是thread-safe(线程安全)
当一个Java类第一次被真正使用到的时候静态资源被初始化、Java类的加载和初始化过程都是线程安全的。所以,创建一个enum类型是线程安全的。
什么是泛型
Java泛型(generics)是JDK5中引入的一个新特性,允许在定义类和接口时候使用参数类型(type parameter)。
声明的类型参数在使用的时候具体的类型替换。泛型最主要的应用是在JDK5中的新集合框架中。
泛型最大的好处是可以提高代码的复用性。以List接口为例,我们可以将String、Integer等类型放入List中,如不用泛型,存放String类型要写一个List接口,存放Integer要写另外一个List接口,泛型可以很好的解决这个问题。
类型擦除
一、各种语言中的编译器是如何处理泛型的
通常情况下,一个编译器处理泛型有两种方式:
1.Code specialization。在实例化一个泛型类或泛型方法时都产生了一份新的目标代码(字节码or二进制代码)。例如,针对一个泛型list,可能需要针对string,integer,float产生三份目标代码。
2.Code sharing。对每个泛型类只生成唯一的一份目标代码;该泛型类的所有实例都映射到这份目标代码上,在需要的时候执行类型检查和类型转换。
C++中的模板(template)是典型的Code specialization实现。C++编译器会为每一个泛型类实例生成一份执行代码。执行代码中的integer list和string list 是两种不同的类型。这样会导致代码膨胀(code bloat)。c#里面泛型无论在程序源码中,编译后的IL中(Intermediate Language,中间语言,这个时候泛型是一个占位符)或是运行期的CLR中都是切实存在的,List\
C++和C#是使用Code specialization的处理机制,前面提到,它有一个缺点,那就是会导致代码膨胀。另外一个弊端是在引用类型系统中,浪费空间,因为引用类型集合中元素本质上都是一个指针。没必要为每个类型都产生一份执行代码。而这也是Java编译器中采用Code sharing方式处理泛型的主要原因。
Java是通过Code sharing方式为每个泛型类创建唯一的字节码表示,并且将该泛型类型的实例都映射到这唯一的字节马上。将多种泛型类型实例映射到唯一的字节码表示是通过类型擦除(type erasure)实现的。
二、什么是类型擦除
前面多次提到这个词:类型擦除(type erasure),那么到底什么是类型擦除呢?
类型擦除指的是通过类型参数合并,将泛型类型实例关联到同一份字节马上。编译器只为泛型类型生成一份字节码,并将其实例关联到这份字节码上。类型擦除的关键在于从泛型类型中清除类型参数的相关信息,并且再必要的时候添加类型检查和类型转换的方法。类型擦除可以简单的理解为将泛型Java代码转为普通的Java代码,只不过编译器更直接点,将泛型Java代码直接转换成普通Java字节码。类型擦除的主要过程如下:1.将所有的泛型参数用其最左边界(最顶级的父类型)类型替换;2.移除所有的类型参数
三、Java编译器处理泛型的过程
1 | //Code 1: |
1 | //反编译后的Code 1: |
可以发现泛型都不见了,程序又变成了Java泛型出现之前的写法,泛型类型都变回了原生类型。
1 | //Code 2: |
1 | //反编译后的Code 2: |
1 | //Code 3: |
1 | //反编译后的Code 3: |
第二个泛型类Comparable\擦除后A被替换为最左边界的Object。Comparable\
四、泛型带来的问题
①当泛型遇到重载:
1 | public class GenericTypes{ |
这段代码,有两个重载的函数,因为他们的参数类型不同,一个是List\
②当泛型遇到catch:
如果我们自定义了一个泛型异常类GenericException,那么,不要尝试用多个catch去匹配不同的异常类型,例如你想要分别获得GenericException、GenericException,这也是有问题的。
③当泛型内包含静态变量
1 | public class StaticTest{ |
答案是2,由于经过类型擦除,所有泛型实例都关联到同一份字节码上,泛型类的所有静态变量是共享的。
五、总结
- 虚拟机中没有泛型,只有普通类和普通方法,所有泛型类的类型参数在编译时都会被擦除,泛型类并没有自己独有的Class类对象。比如并不存在List\
.class或者是List\ .class,而只有List.class。 - 创建泛型对象时请指明类型,让编译器尽早的做参数检查
- 不要忽略编译器的警告信息,那意味着潜在的ClassCastException等着你
- 静态变量是被泛型类的所有实例所共享的。对于声明为MyClass\
的类,访问其中的静态变量的方法仍然是MyClass.myStaticVar。不管是通过new MyClass\ 还是new MyClass\ 创建的对象,都是共享一个静态变量。 - 泛型的类型参数不能在Java异常处理的catch语句中。因为异常处理是由JVM在运行时刻进行的。由于类型信息被擦除,JVM无法区分两个异常类型MyException\
和MyException\ 的。对于JVM来说,它们都是MyException类型的。也就无法执行与异常对应的catch语句。
泛型中K T V E? object等的含义
- E —— Element(在集合中使用,因为集合中存放的是元素)
- T —— Type(Java类)
- V —— Value(值)
- K —— Key(键)
- N —— Number(数值类型)
- ?—— 表示不确定的java类型(无限制通配符类型)
- S、U、V —— 2nd、3rd、4th types
Object - 是所有类的根类,任何类的对象都可以设置给Object引用变量,使用的时候可能需要类型强制转换,但是用了泛型T、E等这些标识符后,在实际用之前类型就已经确定了,不需要再进行类型强制转换。
限定通配符和非限定通配符
限定通配符对类型进行限制,泛型中有两种限定通配符:
表示类型的上界,格式为\<? extends T>,即类型必须为T型类或T的子类;表示类型的下界,格式为\<? super T>,即类型必须为T类型或者是T的父亲。
泛型类型必须用限定内的类型来进行初始化,否则会导致编译错误。
非限定通配符表示可以用任意泛型类型来代替,类行为\
上下界限定符extends和super
\<? extends T>和\<? super T>是Java泛型中的“通配符(Wildcards)”和“边界(Bounds)”的概念。\<? extends T>:是指“上界通配符(Upper Bounds Wildcards)”,即泛型中的类必须为当前类的子类或当前类。\<? super T>:是指“下界通配符(Lower Bounds Wildcards)”,即泛型中的类必须为当前类或者其父类。
1 | public class Food{} |
在testExtends方法中,因为泛型中用的是extends,在向list存放元素的时候,我们并不能确定List中的具体类型,即可能是Apple,也可能是Banana。因此调用add方法时,不论传入new Apple()还是new Banana(),都会出现编译错误。
理解了extends之后,再看super就很容易了,即我们不能确定testSuper方法的参数中泛型是Fruit的哪个父类,因此调用get方法时只能返回Object类型。结合extends可见,在获取泛型元素时,使用extends获取到的是泛型中的上边界的类型。
在使用泛型时,存储元素时用super,获取元素时,用extends。
频繁往外读取内容,适合用上界Extends;经常往里插入的,适合用下界Super。
List\
原始类型List和带参数类型List\
动态代理
静态代理
所谓静态代理,就是代理类由程序员自己编写,在编译期就确定好了。例如:
1 | public interface HelloSerivice{ |
上面的代码比较简单,定义了一个接口和其实现类。这就是代理模式中的目标对象和目标对象接口。接下类定义代理对象。
1 | public class HelloSeriviceProxy implements HelloSerivice{ |
上面就是一个代理类,它也实现了目标对象的接口,并且扩展了say方法。下面是一个测试类:
1 | public class Main(){ |
这就是一个简单的静态的代理模式的实现。代理模式中的所有角色(代理对象、目标对象、目标对象的接口)等都是在编译期就确定好的。
静态代理的真正用途 控制真实对象的访问权限 通过代理对象控制真实对象的使用权限。
避免创建大对象,通过使用一个代理小对象代表一个真实的大对象,可以减少系统资源的消耗,对系统进行优化并提高运行速率。
增强真实对象的功能,这个比较简单,通过代理可以在调用真实对象的方法前后增加额外的功能。
动态代理
静态代理具有一定的局限性,比如使用静态代理模式需要程序员手动写很多代码,这个过程是比较浪费时间和精力的。一旦需要代理的类中的方法比较多,或者需要同时代理多个对象的时候,这无疑会增加很大的复杂度。
动态代理中的代理类并不要求在编译期就确定,而是在运行期动态生成的,从而实现对目标对象的代理功能。
反射是动态代理的一种实现方式。
动态代理和反射的关系
反射是动态代理的一种实现方式。
动态代理的几种实现方式
Java中,实现动态代理有两种方式:
- JDK动态代理:java.lang.reflect包中的Proxy类和InvocationHandler接口提供了生成动态代理类的能力
- Cglib动态代理:Cglib(Code Genneration Library)是一个第三方代码生成类库,运行时在内存中动态生成一个子类对象从而实现对目标对象功能的扩展
JDK动态代理和Cglib动态代理的区别是:JDK的动态代理有一个限制,就是使用动态代理的对象必须实现一个或多个接口。如果想代理没有实现接口的类,就可以使用cglib实现。
Cglib是一个强大的高性能的代码生成包,它可以在运行期扩展Java类与实现Java接口。它广泛的被许多AOP的框架使用,例如Spring AOP和dynaop,为它们提供方法的interception(拦截)。Cglib包的底层是通过使用一个小儿快的字节码处理框架ASM,来转换字节码并生成新的类。不鼓励直接使用ASM,因为它需要你对JVM内部结构包括class文件的格式和指令集都很熟悉。
Cglib与JDK动态代理最大的区别就是:使用JDK动态代理的对象必须实现一个或多个接口,使用cglib代理的对象则无需实现接口,达到代理类无侵入。
Java实现动态代理的大致步骤
1.定义一个委托类和公共接口。
2.自己定义一个类(调用处理器类,即实现InvocationHandler接口),这个类的目的是指定运行时将生成的代理类需要完成的具体任务(包括Preprocess和Postprocess),即代理类调用任何方法都会经过这个调用处理器类。
3.生成代理对象(当然也会生成代理类),需要为它指定:1委托对象;2实现的一系列接口3调用处理器类的实例。因此可以看出一个代理对象对应一个委托对象,对应一个调用处理器实例。
Java实现动态代理主要涉及哪几个类
java.lang.reflect.Proxy:这是生成代理类的主类,通过Proxy类生成的代理类都继承了Proxy类,即DynamicProxyClass extends Proxy。
java.lang.reflect.InvocationHandler:这里称它为“调用处理器”,它是一个接口,我们动态生成的代理类需要完成的具体内容需要自己定义一个类,而这个类必须实现InvocationHandler接口。
动态代理实现
使用动态代理实现功能:不改变Test类的情况下,在方法target之前打印一句话,之后打印一句话。
1 | public class UserServiceImpl implements UserService{ |
JDK动态代理
1 | public class MyInvocationHandler implements InvocationHanler{ |
cglib动态代理
1 | public class CglibProxy implements MethodInterceptor{ |
AOP
SpringAOP中的动态代理主要有两种实现方式,JDK动态代理和CGLIB动态代理。
JDK动态代理通过反射来接收被代理的类,并且要求被代理的类必须实现一个接口。JDK动态代理的核心是InvocationHandler接口和Proxy接口。
如果目标类没有实现接口,那么SpringAOP会使用CGLIB来动态代理目标类。
CGLIB(Code Generation Library),是一个代码生成的类库,可以在运行时动态的生成某个类的子类,注意,CGLIB是通过继承的方式做的动态代理,因此如果某个类被标记为final,那么它无法使用CGLIB做动态代理。
序列化
什么是序列化与反序列化
序列化是将对象转换成可传输格式的过程。是一种数据的持久化手段。一般广泛应用于网络传输,RMI和RPC等场景中。反序列化是序列化的逆过程。序列化是将对象的状态信息转换为可存储或传输的形式的过程。一般是以字节码或XML格式传输。而字节码或XML编码格式可以还原为完全相等的对象。这个相反的过程称为反序列化。
Java如何实现序列化与反序列化
Java对象的序列化与反序列化
在Java中,我们可以通过多种方式来创建对象,并且只要对象没有被回收我们都可以复用该对象。但是,我们创建出来的这些Java对象都是存在于JVM的堆内存中的。只有JVM处于运行状态的时候,这些对象才可能存在。一旦JVM停止运行,这些对象的状态就随之而丢失了。
但是在真实的应用场景中,我们需要将这些对象持久化下来,并且能够在需要的时候把对象重新读取出来。Java的对象序列化可以帮助我们实现该功能。
对象序列化机制(object serialization)是Java语言内建的一种对象持久化方式,通过对象序列化,可以把对象的状态保存为字节数组,并且可以在有需要的时候将这个字节数组通过反序列化的方式转换成对象。对象序列化可以很容易的在JVM中的活动对象和字节数组(流)之间进行转换。
在Java中,对象的序列化与反序列化被广泛应用到RMI(远程方法调用)及网络传输中。
相关接口及类
Java为了方便开发人员将Java对象进行序列化及反序列化提供了一套方便的API来支持。其中包括了如下的接口与类:
- java.io.Serializable
- java.io.Externalizable
- ObjectOutput
- ObjectInput
- ObjectOutStream
- ObjectInputStream
Serializable 接口
类通过实现java.io.Serializable接口以启用其序列化功能。未实现此接口的类将无法使其任何状态序列化或反序列化。可序列化类的所有子类本身都是可序列化的。序列化接口没有方法或字段,仅用于标识可序列化的语义。
当试图对一个对象进行序列化的时候,如果遇到不支持Serializable接口的对象。在此情况下,将抛出NotSerializableException。
如果要序列化的类有父类,要想同时将在父类中定义过的变量持久化下来,那么父类也应该集成java.io.Serializable接口。下面是一个实现了java.io.Serializable接口的类:
1 | public class User1 implements Serializable{ |
通过下面的代码进行序列化及反序列化:
1 | public class SerializableDemo1{ |
Externalizable接口
除了Serializable之外,Java还提供了另外一个序列化接口Externalizable。
为了了解Externalizable接口和Serializable接口的区别,先来看代码,将上面的代码改成使用Externalizable的形式:
1 | public class User1 implements Externalizable{ |
1 | public class ExternalizableDemo{ |
通过上面的实例可以发现,对User1类进行序列化及反序列化之后得到的对象的所有属性的值都变成了默认值。也就是说,之前的那个对象的状态并没有被持久化下来。这就是Externalizable接口和Serializable接口的区别:
Externalizable继承了Serializable,该接口中定义了两个抽象方法:writeExternal()与readExternal()。当使用Externalizable接口来进行序列化与反序列化的时候需要开发人员重写writeExternal()与readExternal()方法。由于上面的代码中,并没有在这两个方法中定义序列化的实现细节,所以输出的内容为空。还有一点值得注意的是:使用Externalizable进行序列化的时候,在读取对象时,会调用被序列化的无参构造去创建一个新的对象,然后再被保存对象的字段的值分别填充到新对象中。所以,实现Externalizable接口的类必须要提供一个public的无参构造器。
按照要求修改之后的代码如下:
1 | public class User2 implements Externalizable{ |
1 | public class ExternalizableDemo{ |
这次,就把之前的对象状态持久化下来。如果User类中没有无参构造函数,在运行时抛出异常:java.io.InvalidClassException。
Serializable和Externalizable有何不同
Java中的类通过实现java.io.Serializable接口以启用其序列化功能。未实现此接口的类将无法使用其任何状态序列化和反序列化。
可序列化的所有子类型本身都是可序列化的。
序列化接口没有字段或方法,仅用于标识可序列化的语义。
当试图对一个对象进行序列化的时候,如果遇到不支持Serializable接口的对象。此情况下,将抛出NotSerializableExcepiton。
如果要序列化的类有父类,要想同时将在父类中定义过的变量持久化下来,那么父类也应该集成java.io.Serializable接口。
Externalizable继承了Serializable,该接口中定义了两个抽象方法:writeExternal()与readExternal()。当使用Externalizable接口进行序列化与反序列化的时候需要开发人员重写writeExternal()与readExternal()方法。若没有实现这两个方法中定义序列化实现的细节,那么序列化后,对象内容为空。实现Externalizable接口的类必须提供一个public的无参构造器。所以,实现Externalizable,并实现writeExternal()和readExternal()方法可以指定序列化哪些属性。
serialVersionUID
序列化是将对象的状态信息转换为可存储或传输的形式的过程。
我们都知道,Java对象是保存在JVM的堆内存中的,也就是说,如果JVM堆不存在了,那么对象也就跟着消失了。
而序列化提供了一种方案,可以让你在即使JVM停机的情况下也能把对象保存下来的方案。就像我们平时用的U盘一样。把Java对象序列化成可存储或传输的形式(如二进制流),比如保存在文件中。这样,当再次需要这个对象的时候,从文件中读取出二进制流,再从二进制流中反序列化出对象。
但是,虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,一个非常重要的一点是两个类的序列化ID是否一致,即serialVersionUID要求一致。
在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体类的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是InvalidCastException。
这样做的目的是为了保证安全,因为文件存储中的内容可能被篡改。
当实现java.io.Serializable接口的类没有显示地定义一个serialVersionUID变量时,Java序列化机制会根据编译的Class自动生成一个serialVersionUID作序列化版本比较用,这种情况下,如果Class文件没有发生变化,就算再编译多次,serialVersionUID也不会变化的。
但是,如果发生了变化,那么这个文件对应的serialVersionUID也就会发生变化。
基于以上原理,如果我们一个类实现了Serializable接口,但是没有定义serialVersionUID,然后序列化。在序列化之后,由于某些原因,我们对该类做了变更,重新启动应用后,我们相对之前序列化过的对象进行反序列化的话就会报错。
为什么serialVersionUID不能随便改
关于serialVersionUID。这个字段到底有什么用?如果不设置会怎么样?为什么《Java开发手册》中有以下规定:
【强制】序列化新增属性时,请不要修改serialVersionUID字段,避免反序列化失败;如果完全不兼容升级,避免反序列化混乱,那么请修改serialVersionUID值。
背景知识
类通过实现java.io.Serializable接口以启用其序列化功能。未实现此接口的类将无法进行序列化或反序列化。可序列化类的所有子类型本身都是可以序列化的。
如果看过Serializable的源码,就会发现,它只是一个空的接口,里面什么东西都没有。Serializable接口没有方法或字段,仅用于标识可序列化的语义。但是,如果一个类没有实现这个接口,想要被序列化的话,就会抛出java.io.NotSerializableException异常。
它是怎么保证只有实现了该接口的方法才能进行序列化与反序列化的呢?原因是在执行序列化的过程中,会执行到以下代码:
1 | if(obj instanceof String){ |
在进行序列化操作时,会判断要被序列化的类是否为Enum、Array和Serializable类型,如果不是则直接抛出NotSerializableException。
Java中还提供了Externalizable接口,也可以实现它来提供序列化能力。Externalizable继承自Serializable,该接口中定义了两个抽象方法:writeExternal()和readExternal()。当使用Externalizable接口进行序列化与反序列化的时候需要开发人员重写writeExternal()和readExternal()方法。否则所有变量的值都会变成默认值。
Transient
transient关键字的作用是控制变量的序列化,在变量声明前加上改关键字,可以阻止该变量被序列化到文件中,在被反序列化后,transient变量的值被设为初始值,如int型是0,对象类型为null。
自定义序列化策略
在序列化过程中,如果被序列化的类中定义了writeObject和readObject方法,虚拟机会试图调用对象类里的writeObject和readObject方法,进行用户自定义的序列化和反序列化。如果没有这样的方法,则默认调用是ObjectOutputStream的defaultWriteObject方法以及ObjectInputStream的defaultReadObject方法。
用户自定义的writeObject和readObject方法可以允许用户控制序列化的过程,比如可以在序列化的过程中动态改变序列化的数值。所以,对于一些特殊字段需要定义序列化的策略的时候,可以考虑使用transient修饰,并自己重写writeObject和readObject方法,如java.util.ArrayList中就有这样的实现。
我们随便找几个Java中实现了序列化接口的类,如String、Integer等,我们可以发现一个细节,那就是这些类除了实现了Serializable外,还定义了——serialVersionUID。
什么是serialversionUID
如果serialVersionUID变了会怎么样
1 | package cn.xiaohupao.Serializable; |
1 | package cn.xiaohupao.Serializable; |
先执行以上代码,把User1对象写入到文件中。然后修改User1类,把serialVersionUID的值改为2L。然后执行以下代码,把文件中的对象反序列化出来:
1 | package cn.xiaohupao.Serializable; |
可以发现抛出了一个java.io.InvalidClassException,并指出serialVersionUID不一致。
这是因为,在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体类的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是InvalidClassException。
这也是《Java开发手册》中规定,在兼容性升级中,在修改类的时候,不要修改serialversionUID的原因。除非是完全不兼容的两个版本。所以,serialVersionUID其实是验证版本一致性的。
如果一个类实现了Serializable接口,就必须手动添加一个 private static final long serialVersionUID变量,并设置初始值。
为什么要明确一个serialVersionUID
如果我们没有在类中明确定义一个serialVersionUID的话,看看会发生什么。
尝试修改上面demo代码,先使用以下类定义一个对象,该类中不定义serialVersionUID,将其写入文件
1 | class User1 implements Serializable{ |
然后修改User1类,向其中添加一个属性。再尝试将其中文件中读取出来,并进行反序列化。
1 | class User1 implements Serializable{ |
同样抛出了InvalidClassException,并且指出两个serialVersionUID不同。从这里看出,系统自己添加了一个serialVersionUID。所以,一旦类实现了Serializable就建议明确的定义一个serialVersionUID。不然在修改类的时候,就会发生异常。
serialVersionUID有两种显示的生成方式:一是默认的1L,比如:private static final long serialVersionUID = 1L;二是根据类名、接口名、成员方法及属性等生成一个64位的哈希字段,例如:private static final long serialVersionUID = xxxxL;后面方式可以借助于IDE生成。
背后原理
反序列化的调用链如下:ObjectInputStream.readObject -> readObject0 -> readOrdinaryObject -> readClassDesc -> readNonProxyDesc -> ObjectStreamClass.initNonProxy
在initNonProxy中,在反序列化过程中,对serialVersionUID做了比较,如果发现不相等,则直接抛出异常。
在getSerialVersionUID方法中:
1 | public long getSerialVersionUID{ |
在没有定义serialVersionUID的时候,会调用computerDefaultSUID方法,生成一个默认的serialVersionUID。
IDEA提示
为了确保我们不会忘记定义serialVersionUID,可以调节以下IDEA的配置,在实现Serializable接口后,如果没有定义serialVersionUID的话,IDEA会进行提示。
总结
serialVersionUID是用来验证版本一致性的。所以在做兼容性升级时,不要改变类中serialVersionUID的值。
如果一个类实现了Serializable接口,一定要记得定义serialVersionUID,否则会发生异常。可以在IDEA中通过设置,让其提醒。之所以会发生异常,是因为反序列化中做了校验,并且如果没有明确定义的话,会根据类的属性自动生成一个。
序列化底层原理
序列化是一种对象持久化的手段。普遍应用在网络传输、RMI等场景中。
Java对象的序列化
Java平台允许我们在内存中创建可复用的Java对象,但一般情况下,只有当JVM处于运行时,这些对象才可能存在,即,这些对象的生命周期不会比JVM的生命周期更长。但在现实应用中,就可能要求在JVM停止运行后能够保持(持久化)指定的对象,并在将来重新读取被保存的对象。Java对象序列化就能够帮助我们实现该功能。
使用Java对象序列化,在保存对象时,会把其状态保存为一组字节,在未来,在未来,再将这些字节组装成对象。需要注意地是,对象序列化保存的是对象的“状态”,即它的成员变量。由此可知,对象序列化不会关注类中的静态变量。
除了在持久化对象时会用到对象序列化之外,当使用RMI(远程方法调用),或在网络中传递对象时,都会用到对象序列化。Java序列化API为处理对象序列化提供了一个标准机制,该API简单易用。
如何对Java对象进行序列化与反序列化
在Java中,只要一个类实现了java.io.Serializable接口,那么它就可以被序列化:
1 | //创建一个User类,用于序列化及反序列化 |
1 | public class SerializableDemo{ |
序列化及反序列化相关知识
- 在Java中,只要一个类实现了java.io.Serializable接口,那么它就可以被序列化。
- 通过ObjectOutputStream和ObjectInputStream对对象进行序列化及反序列化。
- 虚拟机是否允许反序列化,不仅取决于类路径和功能代码是否一致,一个非常重要的一点是两个序列化ID是否一致(就是private static final long serialVersionUID)。
- 序列化并不保存静态变量。
- 想要将父类对象也序列化,就需要让父类也是实现Serializable接口。
- Transient关键字的作用是控制变量的序列化,在变量名声明前加上该关键字,可以阻止该变量被序列化到文件中,在被反序列化后,transient变量的值被设为初始值,如int型为0,对象型为null。
- 服务端给客户端发送序列化对象数据,对象中有一些数据是敏感的,比如密码字符串,希望对该密码字段在序列化时,进行加密,而客户端如果拥有解密的密钥,只有在客户端进行反序列化时,才可以对密码进行读取,这样可以一定程度保证序列化对象的数据安全。
ArrayList的序列化
在介绍ArrayList序列化之前,先来考虑一个问题:如何自定义的序列化和反序列化策略?带着这个问题,我们来看java.util.ArrayList的源码:
1 | public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable{ |
省略了其他成员变量,从上面的代码中可以知道ArrayList实现了java.io.Serializable接口,那么我们就可以对它进行序列化及反序列化。因为elementData是transient的,所以我们认为这个成员变量不会被序列化而保留下来。例如:
1 | public static void main(String[] args) throws IOException, ClassNotFoundExcption{ |
了解ArrayList的人都知道,ArrayList底层是通过数组实现的。那么数组elementDate其实就是用来保存列表中的元素的。通过该属性的声明方式我们知道,它是无法通过序列化持久化下来的。那么为什么上述代码能通过序列化和反序列化把List中的元素保留下来呢?
writeObject和readObject方法
在ArrayList中定义了两个方法:writeObject和readObject。在序列化过程中,如果被序列化的类中定义了writeObject和readObject方法,虚拟机会试图调用对象类里的writeObject和readObject方法,进行用户自定义的序列化和反序列化。
如果没有这样的方法,则默认调用的是ObjectOutputStream的defaultWriteObject方法以及ObjectInputStream的defaultReadObject方法。
用户定义的writeObject和readObject方法可以允许用户控制序列化的过程,比如可以在序列化的过程中动态改变序列化的数值。
来看一下这两个方法的具体实现:
1 | private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException{ |
1 | private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{ |
为什么ArrayList要使用这种方式来实现序列化呢?
why transient
ArrayList实际上是动态数组,每次在放满以后自动增长设定的长度值,如果数组自动增长长度设为100,而实际只放了一个元素,那就会序列化99个null元素。为了保证在序列化的时候不会将这么多null同时进行序列化,ArrayList把元素数组设为transient。
why writeObject and readObject
前面说过,为了防止一个包含大量空对象的数组被序列化,为了优化存储,所以,ArrayList使用transient来声明elementData。但是,作为一个集合,在序列化过程中还必须保证其中的元素可以被持久化下来,所以,通过重写writeObject和readObject方法的方式把其中的元素保留下来。
writeObject方法把elementData数组中的元素遍历的保留到输出流(ObjectOutputStream)中。readObject方法从输入流(ObjectInputStream)中读出对象并保存赋值到elementData数组中。
至此,我们先试着来回答刚刚提出的问题:如何自定义的序列化和反序列化策略?答:可以通过在被序列化的类中增加writeObject和readObject方法。那么问题又来了:虽然ArrayList中写了writeObject和readObject方法,但是这两个方法并没有显示的被调用啊。那么如果一个类中包含了writeObject和readObject方法,那么这两个方法是怎么被调用的呢?
ObjectOutputStream
从上面代码中,我们可以看出,对象的序列化过程通过ObjectOutputStream和ObjectInputStream来实现的,那么带着刚才的问题,我们来分析一下ArrayList中的writeObject和readObject方法到底是如何被调用的呢?
ObjectOutputStream的writeObject的调用栈:writeObject - - - > writeObject0 - - - > writeOrdinaryObject - - - > wirteSerialData - - - > invokeWriteObject
这里看一下invokeWriteObject:
1 | void invokeWriteObject(Object obj, ObjectOutputStream out) throws IOException, UnsupportedOperationException{ |
其中writeObjectMethod.invoke(obj, new Object[] {out});是关键,通过反射的方式调用writeObjectMethod方法。官方是这么解释这个writeObjectMethod的:class-defined writeObject method, or null if none 在我们的例子中,这个方法就是我们在ArrayList中定义的writeObject方法。通过反射的方式被调用了。
至此,我们先试着来回答刚刚提出的问题:如果一个类中包含writeObject和readObject方法,那么这两个方法是怎么被调用的呢?答:在使用ObjectOutputStream的readObject方法时和ObjectInputStream的readObject方法时,会通过反射的方式调用。
至此,我们已经介绍完了ArrayList的序列化方式。那么,不知道有没有提出这样的问题:Serializable明明就是一个空接口,它是怎么保证只有实现该接口的方法才能进行序列化与反序列化的呢?
Serializable接口的定义:
1 | public interface Serializable{ |
若把类中继承Serializable的代码去掉,在执行序列化,将会抛出java.io.NotSerializableException。其实这个问题很好回答,我们回到刚刚ObjectOutputStream的writeObject的调用栈:write - - - >writeObject0 - - - > writeOrdinaryObject - - - >writeSerialData - - ->invokeWriteObject
writeObject0方法中有这么一段代码:
1 | if(obj instanceof String){ |
在序列化操作时,会判断要被序列化的类是否Enum、Array和Serializable类型,如果不是则直接抛出NotSerializableException。
总结
- 如果一个类想被序列化,需要实现Serializable接口。否则将会抛出NotSerializableException异常,这是因为,在序列化操作过程中会对类进行检查,要求被序列化的类必须属于Enum、Array和Serializable类型其中的任何一种。
- 在变量声明前加上transient关键字,可以阻止该变量被序列化到文件中。
- 在类中增加writeObject和readObject方法可以实现自定义序列化策略。
序列化如何破坏单例模式
单例模式,是设计模式中最简单的一种。通过单例模式可以保证系统中一个类只有一个实例而且该实例易于外界访问,从而方便对实例个数的控制并节约系统资源。如果希望在系统中某个类的对象只能存在一个,单例模式是最好的选择方案。
但是,单例模式真的能够实现实例的唯一性吗?
答案是否定的,很多人都知道用反射可以破坏单例模式,除了反射以外,使用序列化与反序列化也同样会破坏单例。
序列化对单例的破坏
1 | //code1 |
1 | //code2 |
输出结果为false,说明:通过对Singleton的序列化与反序列化得到的对象是一个新的对象,这就破坏了Singleton的单例性。
这里,在介绍如何解决这个问题之前,我们先来深入分析一下,为什么会这样?在反序列化的过程中到底发生了什么。
ObjectInputStream
对象的序列化过程是通过ObjectOutputStream和ObjectInputStream来实现的,那么分析一下ObjectInputStream的readObject方法执行情况到底是怎么样的。给出ObjectInputStream的readObject的调用栈:readObject - - ->readObject0 - - ->readOrdinaryObject - - ->checkResolve。这里重点看一下readOrdinaryObject方法的代码片段:
1 | //code3 |
上半部分创建obj对象,就是本方法要返回的对象,可以暂时理解为是ObjectInputStream的readObject返回的对象。obj = desc.isInstantiable() ? desc.newInstance() : null;解释为:desc.isInstantiable() 如果一个序列化的类可以在运行时被实例化,那么该方法返回true。desc.newInstance() 通过反射的方式调用无参构造方法新建一个对象。
所以到目前为止,也就可以解释,为什么序列化可以破坏单例了?答:序列化会通过反射调用无参数构造器创建一个新的对象。
那么接下来我们再看刚开始留下的问题,如何防止序列化/反序列化破坏单例模式。
防止序列化破坏单例模式
先给出解决方案,然后再具体分析原理:只要在Singleton类中定义readResolve就可以解决该问题:
1 | //code4 |
运行测试类:
1 | public class SerializableDemo{ |
输出结果为true。具体原理,我们将分析code3中的第二段代码:
1 | if(obj != null && handles.lookupException(passHandle) == null && desc.hasReadResolveMethod()){ |
hasReadResolveMethod:如果实现了serializable或者externalizable接口的类中包含了readResolve则返回true。
invokeReadResolve:通过反射的方式调用被反序列化的类的readResolve方法。所以,远离也清楚了,主要在Singleton中定义readResolve方法,并在该方法中指定要返回的对象的生成策略,就可以防止单例被破坏。
总结
在涉及到序列化的场景中,要格外注意它对单例的破坏。
protobuf
protocol Buffer(简称Protobuf)是Google出品的性能优异、跨语言、跨平台的序列化库。
2001年初,Protobuf首先在Google内部创建,我们将它称之为proto1,一直以来在Google的内部使用,其中也不断的演化,根据使用者的需求也添加很多新的功能,一些内部库依赖它。几乎每个Google的开发者都会使用它。
Google开始开源它的内部项目时,因为依赖的关系,所以他们首先把Protobuf开源出去。
Apache-Commons-Collections的反序列化漏洞
fastjson的反序列化漏洞
注解
元注解
说简单点,就是定义其他注解的注解。比如Override这个注解,就不是一个元注解。而是通过元注解定义出来的。
1 |
|
其中@Target和@Retention就是元注解。
元注解有四个:@Target(表示该注解可以用于什么地方)、@Retention(表示在什么级别保存该注解信息)、@Documented(将次注解包含在javadoc中)、@Inherited(允许子类继承父类中的注解)。
自定义注解
除了元注解,都是自定义注解。通过元注解定义出来的注解。如我们常用的Override、Autowrite等。日常开发也可以自定义一个注解,这些都是自定义注解。
Java中常用注解使用
- @Override:表示当前方法覆盖了父类的方法
- @Deprecation:表示方法已经过时,方法上有横线,使用时会有警告
- @SuppressWarnings:表示关闭一些警告信息(通知java编译器忽略特定的编译警告)
- @SafeVarargs:表示专门为抑制堆污染警告提供的
- @FunctionalInterface:表示用来指定某个接口必须是函数式接口,否则就会编译错误
注解与反射的结合
注解和反射经常结合在一起使用,在很多框架的代码中都能看到他们结合使用的影子。
可以通过反射来判断类,方法,字段上是否有某个注解以及获取注解中的值,获取某个类中的方法上的注解代码示例如下:
1 | Class<?> clz = bean.getClass(); |
通过isAnnotationPresent判断是否存在某个注解,通过getAnnotation获取注解对象,然后获取值。
一个类的某些字段上被注解标识,在读取该属性的时候,将注解中的默认值赋给这些属性,没有标记的属性不赋值:
1 |
|
定义一个类:
1 |
|
这里给str1加上注解,并利用反射解析并赋值:
1 | public class MyTest{ |
当开发者使用了Annotation修饰了类、方法、Field等成员之后,这些Annotation不会自己生效,必须由开发者提供相应的代码来提取并处理Annotation信息。这些处理提取和处理Annotation的代码统称为APT(Annotation Processing Tool)。
注解的提取需要借助于Java的反射技术,反射比较慢,所以注解使用时也需要谨慎计较时间成本。
如何自定义一个注解?
在Java中,类使用class定义,接口使用interface定义,注解和接口的定义差不多,增加了一个@符号,即@interface,代码如下:
1 | public EnableAuth{ |
注解中可以定义成员变量,用于信息的描述,跟接口中方法的定义类似,代码如下:
1 | public EnableAuth{ |
还可以添加默认值:
1 | public EnableAuth{ |
上面的介绍只是完成自定义注解的第一步,开发中日常使用注解大部分sh用在类上的,方法上的,字段上,示例如下:
1 |
|
Target
用于指定被修饰的注解修饰哪些程序单元,也就是上面说的类,方法,字段。
Retention
用于指定被修饰的注解被保留多长时间,分别SOURCE(注解仅存在于源码中,在class字节码文件不包含),CLASS(默认的保留策略,注解会在class字节码文件中存在,但运行时无法获取),RUNTIME(注解会在class字节码文件中存在,在运行时可以通过反射获取到)三种类型,如果要在程序运行过程中通过反射来获取注解的信息需要将Retention设置为RUNTIME。
Documented
用于指定被修饰的注解类将被Javadoc工具提取成文档。
Inherited
用于指定被修饰的注解类将具有继承性。
Spring常用注解
- @Configuration:把一个类作为IOC容器,它的某个方法头上如果注册了@Bean,就会作为这个Spring容器中的Bean。
- @Scope注解 作用域
- @Lazy(true) 表示延迟初始化
- @Service 用于标注业务层组件
- @Controller 用于标注控制层组件
- @Repository用于标注数据访问组件,即DAO组件
- @Component 泛指组件,当组件不好归类的时候,我们可以使用这个注解进行标注。
- @Scope 用于指定scope作用域的(用在类上)
- @PostConstruct用于指定初始化方法(用在方法上)
- @PreDestory用于指定销毁方法(用在方法上)
- @DependsOn 定义Bean初始化及销毁时的顺序
- @Primary 自动装配时当出现多个Bean候选者时,被注解为@Primary的Bean将作为首选者,否则将抛出异常。
- @Autowired默认按类型装配,如果我们想使用按名称装配,可以结合@Qualifier注解一起使用。如下:@Autowired@Qualifier(“personDaoBean”)存在多个实例配合使用
- @Resource默认按名称装配,当找不到于名称匹配的Bean才会按类型装配
Spring中的这几个注解有什么区别:@Component、@Repository、@Service、@Controller
一、@Component指的是组件:@Controller、@Repository、@Service注解都被@Component修饰,用于代码中区分表现层,持久层和业务层的组件,代码中组件不好归类时可以使用@Component来标注。
二、当前版本只有区分的作用,未来版本可能会添加更丰富的功能。
单元测试
Junit
mock
mockito
内存数据库(H2)
API&SPI
API和SPI的关系和区别
Java中区分API和SPI,通俗的讲:API和SPI都是相对的概念,他们的差别只在语义上,API直接被应用开发人员使用,SPI被框架扩展人员使用。
API Application Programming Interface。大多数情况下,都是实现方来指定接口的不同实现,调用方仅仅依赖却无权选择不同实现。
SPI Service Provider Interface。如果是调用方来指定接口,实现方来针对接口来实现不同的实现。调用方来选择自己需要的实现方。
如何定义SPI
步骤1:定义一组接口(假设是org.foo.demo.IShout),并写出接口的一个或多个实现,(假设是org.foo.demo.animal.Dog、org.foo.demo.animal.Cat)。
1 | public interface IShout{ |
步骤2:在src/main/resources/下建立 /META-INF/services目录,新增一个以接口命名的文件(org.foo.demo.IShout文件),内容是要应用的实现类(这里是org.foo.demo.animal.Dog和org.foo.demo.animal.Cat,没行一个类)。
步骤3:使用ServiceLoader来加载配置文件中指定的实现。
1 | public class SPIMain{ |
SPI的实现原理
看ServiceLoader类的签名类的成员变量:
1 | public final class ServiceLoader<S> implements Iterable<S>{ |
应用程序调用ServiceLoader.load方法
ServiceLoader.load方法内先创建一个新的ServiceLoader,并实例化该类中的成员变量,包括:
- loader(ClassLoader类型,类加载器)
- acc(AccessControlContext类型,访问控制器)
- providers(LinkedHashmap类型,用于缓存加载成功的类)
- lookupIterator(实现迭代器功能)
应用程序通过迭代器接口获取对象实例
ServiceLoader先判断成员变量providers对象中(LinkedHashMap类型)是否有缓存实例对象,如果有缓存,直接返回。如果没缓存,执行类的装载:
- 读取META-INF/services/下的配置文件,获得所有能被实例化的类的名称
- 通过反射方法Class.forName()加载类对象,并用instance()方法将类实例化
- 把实例化后的类缓存到providers对象中(LinkedHashMap类型)
- 返回实例对象
时间处理
时区
时区是地球上的区域使用同一个时间定义。以前,人们通过观察太阳的位置决定时间,这就使得不同经度的地方的时间有所不同。1863年,首次使用时区的概念。时区通过设立一个区域的标准时间部分地解决了这个问题。
世界各个国家位于地球不同位置上,因此不同国家,特别是东西跨度大的国家日出、日落时间必定有所偏差。这些偏差就是所谓的时差。
冬令时和夏令时
夏令时、冬令时的出现,是为了充分利用夏天的日照,所以时钟要往前拨快一小时,冬天再把表往回拨一小时。其中夏令时从3月第二个周日持续到11月第一个周日。
冬令时:北京和洛杉矶时差:16;北京和纽约时差:13
夏令时:北京和洛杉矶时差:15;北京和纽约时差:12
时间戳
时间戳(timestamp),一个能表示一份数据在某个特定时间之前已经存在的、完整的、可验证的数据,通常是一个字符序列,唯一地标识某一刻时间。
时间戳是指格林威治时间1970年01月01日00分00秒(北京时间1970年01月01日08时00分00秒)起至现在的总秒数。通俗的讲,时间戳是一份能够表示一份数据在一个特定时间点已经存在的完整的可验证的数据。
格林威治时间
格林尼治平时(Greenwich Mean Time, GMT)是指位于英国伦敦郊区的皇家格林尼治天文台当地的平太阳时,因为本初子午线被定义为通过那里的经线。
SimpleDateFormat的线程安全性问题
由于SimpleDateFormat比较常用,而且在一般情况下,一个应用中的时间显示模式都是一样的,所以很多人愿意使用如下方式定义SimpleDateFormat:
1 | public class Main{ |
这种定义方式,存在很大的安全隐患。
问题重现
我们来看一段代码,以下代码使用线程池来执行时间输出:
1 | public class Main(){ |
以上代码,其实比较简单,很容易理解。就是循环一百次,每次循环的时候都在当前时间基础上增加一个天数(这个天数随着循环次数而变化),然后把所有日期放入一个线程安全的、带有去重功能的Set中,然后输出Set中元素个数。
正常情况下,以上代码输出结果应该是100。但是实际执行结果是一个小于100的数字。原因就是因为SimpleDateFormat作为一个非线程安全的类,被当做了共享变量在多个线程中进行使用,这就出现了线程安全问题。
在Java开发手册的第一章第六节——并发处理中关于这一点也有明确说明:【强制】SimpleDateFormat是线程不安全的类,一般不要定义static变量,如果定义为static,必须加锁,或者使用DateUtils工具类。
那么,接下我们就来看到底为什么,以及如何解决。
线程不安全原因
通过以上代码,我们发现了在并发场景中使用SipleDateFormat会有线程安全问题。
在SimpleDateFormat类中format方法的实现其实就能发现问题。SimpleDateFormat中的format方法在执行过程中,会使用一个calendar来保存时间。这其实就是问题的关键。
由于我们在声明SimpleDateFormat的时候,使用的是static定义的。那么这个SimpleDateFormat就是一个共享变量,随之,SimpleDateFormat中的calendar也就可以被多个线程访问到。
假设线程1刚刚执行完calendar.setTime把时间设置成2018-11-11,还没等执行完,线程2又执行了calendar.setTime把时间改为2018-11-12。这个时候线程1继续往下执行,拿到的calendar.getTime得到的时间就是线程2改过后的。
除了format方法以外,SimpleDateFormat的parse方法也有同样的问题。所以,不要把SimpleDateFormat作为一个共享变量使用。
如何解决
解决方法有很多,这里介绍三个比较常用的方法。
使用局部变量
1 | for(int i = 0; i< 100; i++){ |
SimpleDateFormat变成了局部变量,就不会被多个线程同时访问到了,就避免了线程安全问题。
加同步锁
除了改成局部变量之外,还有一种方法大家可能比较熟悉,就是对于共享变量进行加锁。
1 | for(int i = 0; i< 100; i++){ |
通过加锁,使多个线程排队顺序执行。避免了并发导致的线程安全问题。其实以上代码还有可以改进的地方,就是可以把颗粒度再设置的小一点,可以只对simpleDateFormat.format这一行加锁,这样效率更高一些。