目录
一、Integer和int的区别?Java为什么要设计封装类?
二、Integer a1 =100 Integer a2 =100 ,a1 ==a2?的运行结果?
五、为什么重写equals()就一定要重写hashCode()方法
十一、刚折腾完Log4J,又爆Spring RCE核弹级漏洞
一、Integer和int的区别?Java为什么要设计封装类?
考察目的
这是一个典型的Java基础问题,本质上来说,这个问题是考察求职者对于面向对象的理解程度。
也是在考察求职者的基本功,越是简单常见的东西,就越能体现求职者的基础扎实程度。这类问题一般是考察1~3年开发经验的同学。
在回答这个问题的时候,尽量从封装类型的特性和功能全方位的去回答。
问题分析
Integer是基本数据类型int的封装类
在Java里面,有八种基本数据类型,他们都有一一对应的封装类型。
基本类型和封装类型的区别有很多,比如:
1、int类型,们可以直接定义一个变量名赋值即可,但是Integer需要使用new关键字创建对象
2、基本类型和Integer类型混合使用时,Java会自动通过拆箱和装箱实现类型转换
3、Integer作为一个对象类型,封装了一些方法和属性,们可以利用这些方法来操作数据。
4、作为成员变量,Integer的默认值是null,而int的默认值是0要是真正列数出来,还可以挖掘更多的差异点。
在Java里面,之所以要对基础类型设计一个对应的封装类型。
是因为Java本身是一门面向对象的语言,对象是Java语言的基础单元,们时时刻刻都在创建对象,也随时都在使用对象,很多时候在传递数据时也需要对象类型,比如像ArrayList、HashMap这些集合,只能存储对象类型,因此从这个点来说,封装类型存在的意义就很大。
其次,封装类型还有很多好处,比如:
1、安全性较好,可以避免外部操作随意修改成员变量的值,保证了成员变量和数据传递的安全性
2、隐藏了实现细节,对使用者更加友好,只需要调用对象提供的方法就可以完成对应的操作
回答
Integer和int的区别有很多,简单说3个方面:
1、Integer的初始值是null,int的初始值是0
2、Integer存储在堆内存,int类型是直接存储在栈空间
3、Integer是对象类型,它封装了很多的方法和属性,们在使用的时候更加灵活。至于为什么要设计封装类型,最主要的原因是Java本身是面向对象的语言,一切操作都是以对象作为基础。
比如像集合里面存储的元素,也只支持存储Object类型,普通类型无法通过集合来存储。
二、Integer a1 =100 Integer a2 =100 ,a1 ==a2?的运行结果?
考察目的
这个问题主要考察Java基础知识,涉及到的知识点还挺多的。
比如,==号表示的内存地址匹配、装箱拆箱、Integer内部内部的设计原理。
大部分同学能够熟练使用Integer,但是不一定去了解过原理,但是作为一个3年以上的开发。
对于Java基础必须要知其然还得知其所以然。
问题分析
按照大家对于Java基础的认知,两个独立的对象用==进行比较,是比较两个对象的内存地址。
那得到的结果必然是false。但是在这个场景中,得到的结果是true。为什么呢?
首先,Integera1=100,把一个int数字赋值给一个封装类型,Java会默认进行装箱操作,也就是调用Integer.valueOf()方法,把数字100包装成封装类型Integer。
其次,在Integer内部设计中,用到了享元模式的设计,享元模式的核心思想是通过复用对象,减少对象的创建数量,从而减少内存占用和提升性能。
Integer内部维护了一个IntegerCache,它缓存了-128到127这个区间的数值对应的Integer类型。
一旦程序调用valueOf方法,如果数字是在-128 到 127 之间就直接在 cache 缓存数 组中去取 Integer 对象。否则 ,就会创建一个新的对象。
所以,对于这个面试题来说,两个 Integer 对象 ,因为值都是 100,并且默认通过装箱 机制调用了 valueOf 方法。
从 IntegerCache 中拿到了两个完全相同的 Integer 实例。
因此用等号比较得到的结果必然是 true。
回答
a1 ==a2 的执行结果是 true。
原因是Integer内部用到了享元模式的设计,针对-128到127之间的数字做了缓存。 使用 Integer a1 =100 这个方式赋值时 ,Java 默认会通过 valueOf 对 100 这个数字进 行装箱操作,从而触发了缓存机制,使得a1和a2指向了同一个 Integer 地址空间。
三、HashMap与HashTable区别
考察目的
这个问题一般考察1~3年开发经验。
考察目的仍然是看求职者对Java基础的掌握程度。
集合是Java基础中非常重要的组件,除了根据不同数据结构选择合适的集合类。还需要从安全性、性能、功能特性角度去了解集合的差异性以及底层工作原理。如果达不到这个层次,在使用的时候遇到问题影响就比较大。
因此这也是人才筛选比较基础的一部分。
问题分析
Hashtable和HashMap都是一个基于hash表实现的K-V结构的集合。
Hashtable是JDK1.0引入的一个线程安全的集合类,因为所有数据访问的方法都加了一个Synchronized同步锁。
Hashtable内部采用数组加链表来实现,链表用来解决hash冲突的问题。
HashMap是JDK1.2引入的一个线程不安全的集合类,HashMap内部也是采用了数组加链表实现,在JDK1.8版本里面做了优化,引入了红黑树。
当链表长度大于等于8并且数组长度大于64的时候,就会把链表转化为红黑树,提升数据查找性能。
回答
1、从功能特性的角度来说
- a.HashTable 是线程安全的,而HashMap不是。
- b.HashMap的性能要比HashTable更好,因为,HashTable采用了全局同步锁来保证安全性,对性能影响较大。
2、从内部实现的角度来说
a.HashTable使用数组加链表、HashMap采用了数组+链表+红黑树。
b.HashMap初始容量是16、HashTable初始容量是11。
c.HashMap可以使用null作为key,HashMap会把null转化为0进行存储,而Hashtable不允许。
最后,他们两个的key的散列算法不同,HashTable直接是使用key的hashcode对数组长度做取模。
而HashMap对key的hashcode做了二次散列,从而避免key的分布不均匀问题影响到查询性能。
四、Java反射的优缺点?
问题分析
反射是Java语言里面比较重要的一个特征。
它能够在程序运行的过程中去构造任意一个类对象、并且可以获取任意一个类的成员变量、成员方法、属性,以及调用任意一个对象的方法。
通过反射的能力,可以让Java语言支持动态获取程序信息以及动态调用方法的能力。在Java里面,专门有一个java.lang.reflect用来实现反射相关的类库,包括Construct、Field、Method等类,分别用来获取类的构造方法、成员变量、方法信息。
反射的使用场景还挺多的,比如在动态代理的场景中,使用动态生成的代理类来提升代码的复用性。
在Spring框架中,有大量用到反射,比如用反射来实例化Bean对象。
回答
Java反射的优点:
1、增加程序的灵活性,可以在运行的过程中动态对类进行修改和操作
2、提高代码的复用率,比如动态代理 ,就是用到了反射来实现
3、可以在运行时轻松获取任意一个类的方法、属性,并且还能通过反射进行动态调用
Java 反射的缺点:
1、反射会涉及到动态类型的解析,所以JVM无法对这些代码进行优化,导致性能要比非反射调用更低。
2、使用反射以后,代码的可读性会下降
3、反射可以绕过一些限制访问的属性或者方法,可能会导致破坏了代码本身的抽象性
五、为什么重写equals()就一定要重写hashCode()方法
问题分析
关于这个问题,首先需要深入了解一下equals这个方法。
这个equals方法是String这个类里面的实现。
从代码中可以看到,当调用equals比较两个对象的时候,会做两个操作
1. 用==号比较两个对象的内存地址,如果地址相同则返回true
2. 否则,继续比较字符串的值,如果两个字符串的值完全相等,同样返回true
那equals和hashCode()有什么关系呢?
首先,Java里面任何一个对象都有一个native的hashCode()方法
其次,这个方法在散列集合中会用到,比如HashTable、HashMap这些,当添加元素的时候,需要判断元素是否存在,而如果用equals效率太低,所以一般是直接用对象的hashCode的值进行取模运算。
O 如果table中没有该hashcode值,它就可以直接存进去,不用再进行任何比较了;
O 如果存在该hashcode值,就调用它的equals方法与新元素进行比较,相同的话就不存了,不相同就散列其它的地址,所以这里存在一个冲突解决的问题,这样一来实际调用equals方法的次数就大大降低了.
hashCode的值默认是JVM使用随机数来生成的,两个不同的对象,可能生成的HashCode会相同。
这种情况在Hash表里面就是所谓的哈希冲突,通常会使用链表或者线性探测等方式来解决冲突问题。
但是如果两个完全相同的对象,也就是内存地址指向同一个,那么他们的hashCode一定是相同的。
了解了equals和hashCode的关系以后,再来分析这个面试题。
在理论情况下,如果x.equals(y)==true,如果没有重写equals方法,那么这两个对象的内存地址是同一个,意味着hashCode必然相等。
但是如果们只重写了equals方法,就有可能导致hashCode不相同。
一旦出现这种情况,就导致这个类无法和所有集合类一起工作。
所以,在实际开发中,约定俗成了一条规则,重写equals方法的同时也需要重写hashCode方法。
回答
如果只重写equals方法,不重写hashCode方法。
就有可能导致a.equals(b)这个表达式成立,但是hashCode却不同。
那么这个只重写了equals方法的对象,在使用散列集合进行存储的时候就会出现问题。
因为散列结合是使用hashCode来计算key的存储位置,如果存储两个完全相同的对象,但是有不同的hashcode,就会导致这两个对象存储在hash表的不同位置,当们想根据这个对象去获取数据的时候,就会出现一个悖论,一个完全相同的对象会在存储在hash表的两个位置,造成大家约定俗成的规则,出现一些不可预料的错误。
六、介绍下策略模式和观察者模式?
问题分析
在Java里面,有23种设计模式
而在实际开发中,用到的设计模式屈指可数,主要有两方面的原因
目前的开发模式,基本上按照MVC这一套在搞,大部分业务逻辑的实现都不复杂
对设计模式的理解不够,只能生搬硬套,不仅仅没带来好处,还让程序处理变得更
麻烦
但是,设计模式确实是无数前辈在软件开发过程中总结的一些经验,他们能够使得程序更加灵活可扩展。
有人把它总结成了公式化的23种设计模式,导致大家以为按照这个公式去搬运就可以,但实际上认为。
设计模式应该是一种软件设计的思想或者方法论,它不应该固化成某种特定的公式,它的运用应该更加灵活。
这23种设计模式可以分成三种类型分别是创建型、结构型、行为型。
策略模式和观察者模式属于行为型模式。
行为型模式主要用来描述多个类和对象之间的相互协同完成单个对象无法单独完成的任务,除了这两种以外,还包括模版方法、状态模式、责任链模式、解释器模式等。
高手回答
策略模式和观察者模式属于行为型模式。
策略模式主要是用在根据上下文动态控制类的行为的场景:
一方面可以解决多个if...else判断带来的代码复杂性和维护性问题
另一方面,把类的不同行为进行封装,使得程序可以进行动态的扩展和替换,增加了程序的灵活性。像支付路由这种场景,就可以使用策略模式实现。
观察者模式主要用在一对多的对象依赖关系的中,实现某一个对象状态变更之后的感知的场景
一方面可以降低对象依赖关系的耦合度,弱化依赖关系。
另一方面,通过这种状态通知机制,可以保证这些依赖对象之间的状态协同。
在Spring源码里面有大量运用这种观察者模式实现事件的传播和感知。
总结
要想真正理解设计模式的精髓并且能灵活运用,需要多去阅读一些优秀的源码,比如Spring,否则设计模式永远只会停留在纸上无法落地。
七、谈谈什么是零拷贝?
在实际应用中,如果们需要把磁盘中的某个文件内容发送到远程服务器上,那么它必须要经过几个拷贝的过程。从磁盘中读取目标文件内容拷贝到内核缓冲区,CPU控制器再把内核缓冲区的数据赋值到用户空间的缓冲区中,接着在应用程序中,调用write()方法,把用户空间缓冲区中的数据拷贝到内核下的SocketBuffer中。最后,把在内核模式下的SocketBuffer中的数据赋值到网卡缓冲区( NIC Buffer)网卡缓冲区再把数据传输到目标服务器上。
在这个过程中们可以发现,数据从磁盘到最终发送出去,要经历4次拷贝,而在这四次拷贝过程中,有两次拷贝是浪费的,分别是:
1、从内核空间赋值到用户空间。
2、从用户空间再次复制到内核空间。
除此之外,由于用户空间和内核空间的切换会带来CPU的上线文切换,对于CPU性能也会造成性能影响。而零拷贝,就是把这两次多于的拷贝省略掉,应用程序可以直接把磁盘中的数据从内核中直接传输给Socket,而不需要再经过应用程序所在的用户空间。
零拷贝通过DMA(Direct Memory Access)技术把文件内容复制到内核空间中的Read Buffer,接着把包含数据位置和长度信息的文件描述符加载到 Socket Buffer 中 ,DMA 引擎 直接可以把数据从内核空间中传递给网卡设备。
在这个流程中,数据只经历了两次拷贝就发送到了网卡中,并且减少了2次cpu的上下文切换,对于效率有非常大的提高。
所以,所谓零拷贝,并不是完全没有数据复制,只是相对于用户空间来说,不再需要进行数据拷贝。对于前面说的整个流程来说,零拷贝只是减少了不必要的拷贝次数而已。
在程序中如何实现零拷贝呢?
1、在Linux中,零拷贝技术依赖于底层的sendfile()方法实现。
2、在Java中,FileChannal.transferTo()方法的底层实现就是sendfile()方法。
3、除此之外,还有一个mmap的文件映射机制。
八、SortedSet和List异同点?
问题分析
在面试过程中遇到的xx技术和xx技术的异同点问题。
大家必须要深刻了解这两种技术的特征和优缺点,如果单纯的去背诵,很难记住。
这个问题中,List大家都已经非常熟悉了。简单分享一下SortedSet(如图)。
在Java的整个集合体系中,集合可以分成两个体系,一个是Collection存储单个对象的集合,另一个是k-v结构的Map集合。
SortedSet是Collection体系下Set接口下的派生类,而Set集合的特征是不包含重复的元素的集合。
了解了这个特点,才能更加准确的回答这个问题。
问题解答
首先说一下相同点:
1、都可以用来存储一组有序的元素。
2、都支持随机访问和按照索引位置插入元素。
3、都是派生自Collection接口。
不同点:
1、SortedSet是一个有序的集合,不允许元素的重复,而List是一个有序的列表,允许元素的重复。
2、SortedSet可以按照元素的自然顺序或者自定义比较器进行排序,而List只能按照元素的添加顺序排序。
3、在 SortedSet中,元素的添加和删除操作的时间复杂度为O(logn),而在List中,元素的添加和删除操作的时间复杂度为O(n),因为需要移动其他元素的位置。
4、SortedSet可以方便地进行范围查询操作,例如获取某个区间内的元素,而List只能通过遍历实现范围查询。
九、为什么阿里Java手册禁止使用存储过程?
问题分析
一般来说,每个公司都会有自己的开发规范,开发规范存在的目的是减少一些低级错误和提升代码后期的维护性。
就这个问题来说,存储过程并不是绝对不能使用,只是在不同规模的公司,使用存储过程一定是会带来一定的影响。
因此,候选人可以从存储过程本身的缺点切入去解释就行了。
问题解答
禁止使用存储过程,主要有以下考虑点:
1. 存储过程的所有逻辑都在数据库层面,这会导致代码的可读性下降。
2. 存储过程可能包含复杂的业务逻辑,会导致数据库的负载增加,进而影响系统的性能。
3. 互联网公司的数据库会有专门的人来维护,开发人员是无法直接访问生产库的,当们把业务逻辑写到数据库存储过程中,那后续在对业务进行升级的时候,就需要同步升级存储过程,不仅会造成工作职能的冲突,还会影响到业务逻辑的维护性问题。
4. 存储过程可能会难以调试和测试
十、为什么阿里巴巴强制要求使用包装类型定义属性?
问题分析
这是一个开发规范的问题,开发规范更多的是为了后续程序的可维护性以及避免出现一些低级的错误影响程序的稳定。
所以对于一些规模比较大的公司,对规范的定义就会更加严格和细致。
因此回答这个问题的时候,可以往包装类型的优势去切入,从而保证自己的回答内容在面试官的预期内。
问题解答
认为主要有以下几个方面的原因:
1. 默认值问题:使用基本数据类型定义属性时,如果没有给属性赋初始值,会使用默认值(如int的默认值为0),而使用包装类型定义属性,如果没有给属性赋初始值,属性的值为null,这样可以更加清晰地表达属性的状态。
2. 拆箱问题:在一些特定场景下,如果使用基本数据类型定义属性,需要进行多次装箱和拆箱操作,这个操作会带来额外的性能开销和代码复杂度。而使用包装类型定义属性,可以避免这个问题,提高代码的效率和可读性。
3. Java中的泛型中只能使用对象类型,如果要在泛型编程中使用基本类型,就必须使用对应的包装类型。
4. 提供了基本类型所不具备的方法和属性,比如equals()、hashCode()、toString(),这些方法在某些特定场景中会比较有用。
十一、刚折腾完Log4J,又爆Spring RCE核弹级漏洞
继Log4J爆出安全漏洞之后,又在深夜,Spring的github上又更新了一条可能造成RCE(远程命令执行漏洞)的问题代码,随即在国内的安全圈炸开了锅。有安全专家建议升级到JDK9以上,有些专家又建议回滚到JDK7以下,一时间小伙伴们不知道该怎么办了。大家来看一段动画演示,怎么改都是“将军"。
大家不要慌,给大家先临时支个招,后面再出教程。首先叫大家怎么排查哪些项目存在风险,然后,再介绍修复方案。
1、第一步:排查方法
排查的主要目的是确定你的项目是否使用了Spring框架。当然,你的项目有没有使用Spring框架开发者都知道。但是,如果公司项目比较多,为了规避风险,还要对一些老项目要进行排查。那老项目如何确定是否使用了Spring框架呢?
方法很简单,不管的你的项目是用war独立部署还是用jar包独立部署,只需要对应的war或者jar包,将后缀改为zip包,然后将zip解压。在解压后的目录中搜索是否存在spring-beans-开头的jar包或者CachedInrospectionResults.class文件。如果存在就可以确定该项目使用了Spring。
确定项目使用了Spring框架以后,如何来修复可能存在的风险呢?
2、第二步:修复方案
目前为止,Spring官方还没有给出解决方案。先教大家一个简单粗暴的方案,可以临时解决问题。
1、如果安装了WAF防护,也就是Web应用防火墙,只需要追加这样一个防护规则“class.Class..class.*.Class”,防止远程下载。
2、如果没有安装WAF,只需要在拦截器中增加对class.Class..class.*.Class后缀请求的拦截就可以了。
按照这两步操作完之后,大家记得对业务运行情况进行测试,避免对已有功能造成影响。此次爆出的漏洞,攻击原理和之前Log4J爆出的漏洞原理差不多。
十二、3分钟轻松理解单线程下的HashMap工作原理
HashMap主要是用来处理键值对数据。随着JDK版本的更新,JDK1.8对HashMap对底层也做了一些优化。今天带大家一起来结合源码,深入浅出HashMap工作原理。HashMap是基于哈希表对Map接口的实现类,它的特点是访问数据的速度快,并不是按顺序来遍历。HashMap提供所有可选的映射操作,但不能保证映射顺序不变,并且允许使用空值和空键。HashMap也并不是线程安全的,当存在多个线程同时写入时,可能会导致数据不一致的情况。
1、HashMap中的关键属性
要透彻理解HashMap原理,首先需要对以下几个关键属性有一个基本的认识。
我们看到,HashMap的源码片段:
第一个属性loadFactor,它是负载因子,默认值是0.75,表示扩容前。
第二个属性threshold它是记录HashMap所能容纳的键值对的临界值,它的计算规则是负载因子乘以数组长度。
第三个属性size,它用来记录HashMap实际存在的键值对的数量。
第四个属性modCount,它用来记录HashMap内部结构发生变化的次数。
第五个是常量属性DEFAULT_INITIAL_CAPACITY,它规定的默认容量是16。
2、HashMap的存储结构
HashMap采用的是的存储结构。HashMap的数组部分称为Hash桶,数组元素保存在一个叫做table的属性中。当链表长度大于等于8时,链表数据将会以红黑树的形式进行存储,当长度降到6时,又会转成链表形式存储。
每个Node节点,保存了用来定位数组索引位置的hash值、Key、Value和链表指向的下一个Node节点。而Node类是HashMap的内部类,它实现了Map.Entry接口,它的本质其实可以简单的理解成就是一个键值对。来看一下源码。
3、HashMap的工作原理
当们向HashMap中插入数据时,首先要确定Node在数组中的位置。那如何确定Node的存储位置呢?以添加Key为字符串“e”的对象为例:
展示运算过程
HashMap首先调用hashCode()方法,获取Key的hashCode值为h。然后对h值进行高位运算;将h右移16位取得h的高16位,与进行异或运算,最后得到h的值与(table.length-1)进行与运算获得该对象的保留位,最后计算出下标。当然,这是最官方的描述。有的小伙伴可能已经迷糊了。其实,这段运算过程,简单地理解成求模取余法。
就是用hash值和数组的长度减1,取模,最后得到数组的下标,这样可以保证数组下标不越界。只不过,位运算是二进制运算,效率更高。
假设有“a”、“b”、“d”、“r”,“t”,“e”的Key。
通过计算得到的下标分别为1、2、4、2、4、5
它们的插入顺序如图所示:
如果们再次插入"a",“g”,“i”,null四个Key,来看HashMap的内部变化。
当插入第二个以a为Key的对象时,会将新值赋值给a的值。当插入的对象大小超过临界值时,HashMap将新建一个桶数组并重新赋值(当然,JDK1.7和1.8重新赋值的方式略有不同)这个时候,HashMap键的输出顺序为null、a、b、r、d、t、e、g、i
十三、为什么HashMap会产生死循环?
HashMap死循环是一个比较常见、也是比较经典的面试题,在大厂的面试中也经常被问到。HashMap的死循环问题只在JDK1.7版本中会出现,主要是HashMap自身的工作机制,再加上并发操作,从而导致出现死循环。JDK1.8以后,官方彻底解决了这个问题。
1、数据插入原理
在分析原因之前,先带大家了解一下JDK1.7中HashMap插入数据的原理,来看图演示:
由于JDK1.7中HashMap的底层存储结构采用的是数组加链表的方式:
而HashMap在数据插入时又采用的是头插法,也就是说新插入的数据会从链表的头节点进行插入:
因此,HashMap正常情况下的扩容就是是这样一个过程。们来看,旧HashMap的节点会依次转移到新的HashMap中,旧HashMap转移链表元素的顺序是A、B、C,而新HashMap使用的是头插法插入,所以,扩容完成后最终在新HashMap中链表元素的顺序是C、B、A。
2、导致死循环的原因
接下来,通过动画演示的方式,带大家彻底理解造成HashMap死循环的原因。我们按以下三个步骤来还原并发场景下HashMap扩容导致的死循环问题:
第一步:线程启动,有线程T1和线程T2都准备对HashMap进行扩容操作,此时T1和T2指向的都是链表的头节点A,而T1和T2的下一个节点分别是T1.next和T2.next,它们都指向B节点。
第二步:开始扩容,这时候,假设线程T2的时间片用完,进入了休眠状态,而线程T1开始执行扩容操作,一直到线程T1扩容完成后,线程T2才被唤醒。
T1完成扩容之后的场景就变成动画所示的这样。
因为HashMap扩容采用的是头插法,线程T1执行之后,链表中的节点顺序发生了改变。但线程T2对于发生的一切还是不可知的,所以它指向的节点引用依然没变。如图所示,T2指向的是A节点,T2.next指向的是B节点。
当线程T1执行完成之后,线程T2恢复执行时,死循环就发生了。
因为T1执行完扩容之后,B节点的下一个节点是A,而T2线程指向的首节点是A,第二个节点是B,这个顺序刚好和T1扩容之前的节点顺序是相反的。T1执行完之后的顺序是B到A,而T2的顺序是A到B,这样A节点和B节点就形成了死循环。
3、解决方案
避免HashMap发生死循环的常用解决方案有三个:
1)、使用线程安全的ConcurrentHashMap替代HashMap,个人推荐使用此方案。
2)、使用线程安全的容器Hashtable替代,但它性能较低,不建议使用。
3)、使用synchronized或Lock加锁之后,再进行操作,相当于多线程排队执行,也会影响性能,不建议使用。
4、总结
HashMap死循环只发生在JDK1.7版本中,主要原因是JDK1.7中的HashMap,在头插法加链表加多线程并发加扩容这几个情形累加到一起就会形成死循环。多线程环境下建议采用ConcurrentHashMap替代。在JDK1.8中,HashMap改成了尾插法,解决了链表死循环的问题。
十四、哪些情况下的单例对象可能会破坏?
1、单例模式的定义
关于单例模式的定义,官方原文是这样描述的:
Ensure a class has only one instance,and provide a global point of access to it.
大致意思是,确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。
单例模式的写法相信只要是程序员应该都会,也很非常简单,这里就不一一列举了。今天,要重点要给大家分析的是,在Java中,哪些单例对象是最有可能被破坏的。
2、单例被破坏的五个场景
把可能出现单例被破坏的情况,一共归纳为五种,分别为多线程破坏单例、指令重排破坏单例、克隆破坏单例、反序列化破坏单例、反射破坏单例。
下面详细分析一下每种情况并给出解决方案:
第一种:多线程破坏单例
在多线程环境下,线程的时间片是由CPU自由分配的,具有随机性,而单例对象作为共享资源可能会同时被多个线程同时操作,从而导致同时创建多个对象。当然,这种情况只出现在懒汉式单例中。如果是饿汉式单例,在线程启动前就被初始化了,不存在线程再创建对象的情况。
如果懒汉式单例出现多线程破坏的情况,给出以下两种解决方案:
1、改为DCL双重检查锁的写法。
2、使用静态内部类的写法,性能更高。
第二种:指令重排破坏单例
指令重排也可能导致懒汉式单例被破坏。来看这样一句代码:
Instance = new Singleton();
看似简单的一段赋值语句:instance = new Singleton();
其实 JVM 内部已经被转换为多条执行指令:
memory = allocate(); 分配对象的内存空间指令
ctorInstance(memory); 初始化对象
instance = memory; 将已分配存地址赋值给对象引用
1、分配对象的内存空间指令 ,调用 allocate()方法分配内存。
2、调用 ctorInstance()方法初始化对象
3、将已分配存地址赋值给对象引用
但是经过重排序后 ,执行顺序可能是这样的:
memory = allocate(); 分配对象的内存空间指令
instance = memory; 将已分配存地址赋值给对象引用
ctorInstance(memory); 初始化对象
1、分配对象的内存空间指令
2、设置 instance 指向刚分配的内存地址
3、初始化对象
我们可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化的指令被排在了后面,在线程T1初始化完成这段内存之前,线程T2虽然进不去同步代码块,但是在同步代码块之前的判断就会发现instance不为空,此时线程T2
获得instance对象,如果直接使用就可能发生错误。
如果出现这种情况,该如何解决呢?只需要在成员变量前加volatile,保证所有线程的可见性就可以了。
private static volatile Singleton instance = null;
第三种:克隆破坏单例
在Java中,所有的类就继承自Object,也就是说所有的类都实现了clone()方法。如果是深clone(),每次都会重新创建新的实例。那如果们定义的是单例对象,岂不是也可调用clone()方法来反复创建新的实例呢?确实,这种情况是有可能发生的。为了避免发生这样结果,们可以在单例对象中重写clone()方法,将单例自身的引用作为返回值。这样,就能避免这种情况发生。
第四种:反序列化破坏单例
因此,只需要重写readResolve()方法,将返回值设置为已经存在的单例对象,就可以保证反序列化以后的对象是同一个了。之后再将反序列化后的对象中的值,克隆到单例对象中。
第五种:反射破坏单例
以上讲的所有单例情况都有可能被反射破坏。因为Java中的反射机制是可以拿到对象的私有的构造方法,也就是说,反射可以任意调用私有构造方法创建单例对象。当然,没有人会故意这样做,但是如果出现意外的情况,该如何处理呢?家两种解决方案:
第一种方案:在所有的构造方法中第一行代码进行判断,检查单例对象是否已经被创建,如果已经被创建,则抛出异常。这样,构造方法将会被终止调用,也就无法创建新的实例。
第二种方案:将单例的实现方式改为枚举式单例,因为在JDK源码层面规定了,不允许反射访问枚举。
3、总结
最后总结一下:
1、在所有单例写法中,如果程序不是太复杂,单例对象又不多,推荐使用饿汉式单例。
2、但如果经常发生多线程并发情况下,推荐使用静态内部类和枚举式单例,的《设计模式就该这样学》这本书中,也推荐这样的写法。
十五、责任链模式的实现原理
责任链模式应用场景非常多、比如拦截器、过滤器等等。但是要彻底理解责任链的实现原理还是有一定难度的,因此,责任链模式的实现原理也就成为了一道互联网大厂的高频面试题。今天,给小伙伴们来详细地掰一掰,保证让你彻底搞明白。
1、责任链模式的定义
关于责任链模式的定义,官方原文是这样描述的:
Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handles it.
翻译过来就是:
将链中每一个节点都看作一个对象,每个节点处理的请求均不同,且内部自动维护下一个节点对象。当一个请求从的首端发出时,会沿着责任链预设的路径依次传递到每一个节点对象,直至被链中的某个对象处理为止。
简答一句话总结就是:将处理不同逻辑的对象连接成一个链表结构,每个对象都保存下一个节点的引用。
我们在平时处理工作中的时候,往往需要各部门协同合作来完成某一个任务。因为,每个部门都有各自的职责,所以,很多时候事情完成一半,就要会转交到下一个部门,甚至还要盖章,直到所有部门都审批通过,事情才能完成。这就相当于将皮球层层往上踢。
还记得小时候追过的一个电视剧,80后应该都看过,有位太极高手闯七层宝塔,每一层都住了一位武林高手,层层往上“过五关,斩六将”,打赢才能往上一层,打输了就踢回去,最后登顶.当时看得是热血沸腾,这也是责任链的一种场景。
所以责任链模式又被戏称为“踢皮球”模式。那责任链模式的实现原理又是怎样的呢?
2、责任链模式的实现原理
责任链又分为单向责任链和双向责任链,单向责任链比较简单也容易理解,双向责任链相当于是一个执行闭环,较为复杂。
们先来分析单向责任链。它的结构是这样设计的,首先,设计一个单向链表的上下文Context,保存链表的头(head)引用和尾(tail)引用,Context的代码结构是这样的。
然后,在上下文中加入Handler,也就是处理业务逻辑的节点类,每个Hnandler都保存了下一个执行节点的引用,形成一条完整的执行链路。Handler的通用代码结构是这样的。
J2EE中的Filter过滤器、Spring中的Interceptor拦截器都是采用这样的单向链表的设计。
那双向链表又如何设计呢?它和单向链表的基本结构一致,们来看,它只是在Handler中增加了对上一个节点的引用。这样责任链就行了一个执行闭环,就好比是环线地铁。来看它的通用代码结构是这样的。
Netty中的Piepline管道就是采用这样的双向链表的设计。
责任链模式一般还会结合建造者模式来使用,实现链式编程。
3、责任链模式的优缺点
那这样设计又有什么优点和缺点呢?先给大家总结一下它的优点:
(1)实现了将请求与处理完全解耦。
(2)请求处理者只需关注自己职责范围内的请求进行处理即可,对于不是职责范围内的请求,直接转发给下一个节点对象。
(3)具备链式传递处理请求的功能,请求发送者不需要知晓链路结构,只需等待请求处理结果即可。
(4)链路结构灵活,可以通过改变链路结构动态地新增或删减责任。
那这样设计,会有哪些缺点呢?也给大家总结一下:
(1)如果责任链路太长或者处理时间过长,会影响程序执行的整体性能。
(2)如果节点对象存在循环引用,有可能会造成死循环,从而导致程序崩溃。