2022Java面试笔记(上)
2.Java四种引用(强、软、弱、虚)
从JDK1.2版本开始,把对象的引用分为四种级别,从而使程序更加灵活的控制对象的生命周期。这四种级别由高到低依次为:强引用、软引用、弱引用和虚引用。
Java中提供这四种引用类型主要有两个目的:第一是可以让程序员通过代码的方式决定某些对象的生命周期;第二是有利于JVM进行垃圾回收。
1.强引用
Object obj =new Object();String str = "StrongReference";
上述Object这类对象就具有强引用,属于不可回收的资源,垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError
错误,使程序异常终止,也不会靠回收具有强引用的对象,来解决内存不足的问题。
public class StrongReference {public static void main(String[] args) {new StrongReference().method1();}public void method1(){Object object=new Object();Object[] objArr=new Object[Integer.MAX_VALUE];}
}
运行结果:
当运行至Object[] objArr = new Object[Integer.MAX_VALUE]
时,如果内存不足,JVM会抛出OOM错误也不会回收object指向的对象。不过要注意的是,当method1运行完之后,object和objArr都已经不存在了
,所以它们指向的对象都会被JVM回收。
值得注意的是:如果想中断强引用和某个对象之间的关联或者回收强引用对象,可以显式地将引用赋值为null
,这样的话JVM就会在合适的时间,进行垃圾回收。比如ArraryList类的clear
方法中就是通过将引用赋值为null来实现清理工作的
public void clear() {modCount++;// Let gc do its workfor (int i = 0; i < size; i++)elementData[i] = null;size = 0;
}
在ArrayList类中定义了一个私有的变量elementData数组,在调用方法清空数组时可以看到为每个数组内容赋值为null。不同于elementData=null,强引用仍然存在,避免在后续调用 add()等方法添加元素时进行重新的内存分配。使用如clear()方法中释放内存的方法对数组中存放的引用类型特别适用,这样就可以及时释放内存。
下图是堆区的内存示意图,分为新生代,老生代,而垃圾回收主要也是在这部分区域中进行。
2.软引用(SoftReference)
软引用是用来描述一些有用但并不是必需的对象,在Java中用java.lang.ref.SoftReference
类来表示。对于软引用关联着的对象,只有在内存不足的时候JVM才会回收该对象。因此,这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现缓存:比如网页缓存、图片缓存
等。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被JVM回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。
Object obj = new Object();
ReferenceQueue queue = new ReferenceQueue();
SoftReference reference = new SoftReference(obj, queue);
//强引用对象滞空,保留软引用
obj = null;
当内存不足时,软引用对象被回收时,reference.get()为null,此时软引用对象的作用已经发挥完毕,这时将其添加进ReferenceQueue 队列中
如果要判断哪些软引用对象已经被清理:
SoftReference ref = null;
while ((ref = (SoftReference) queue.poll()) != null) {//清除软引用对象
}
当内存足够大时可以把obj存入软引用,取数据时就可从内存里取数据,提高运行效率
软引用在实际中有重要的应用,例如浏览器的后退按钮,这个后退时显示的网页内容可以重新进行请求或者从缓存中取出:
(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出这时候就可以使用软引用
3.弱引用(WeakReference)
弱引用也是用来描述非必需对象的,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference
类来表示。
弱引用与软引用的区别在于:
- 只具有弱引用的对象拥有更短暂的生命周期。
- 在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
- 所以被软引用关联的对象只有在内存不足时才会被回收,而被弱引用关联的对象在JVM进行垃圾回收时总会被回收。
import java.lang.ref.WeakReference;public class WeakRef {public static void main(String[] args) {WeakReference<String> sr = new WeakReference<String>(new String("hello"));System.out.println(sr.get());System.gc(); //通知JVM的gc进行垃圾回收System.out.println(sr.get());}
}
运行结果:
在使用软引用和弱引用的时候,我们可以显示地通过System.gc()
来通知JVM进行垃圾回收,但是要注意的是,虽然发出了通知,JVM不一定会立刻执行,也就是说这句是无法确保此时JVM一定会进行垃圾回收的。
弱引用还可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
Object obj = new Object();//只要obj还指向对象就不会被回收
ReferenceQueue queue = new ReferenceQueue();
WeakReference wr = new WeakReference(obj, queue);
//强引用对象滞空,保留软引用
obj = null;
当要获得WeakReference引用的object时, 首先需要判断它是否已经被回收,如果wr.get()
方法为空, 那么说明obj指向的对象已经被回收了。
应用场景:如果一个对象是偶尔的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么应该用 Weak Reference 来记住此对象。或者想引用一个对象,但是这个对象有自己的生命周期,你不想介入这个对象的生命周期,这时候就应该用弱引用,这个引用不会在对象的垃圾回收判断中产生任何附加的影响。
4.虚引用(PhantomReference)
虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期,在java中用java.lang.ref.PhantomReference
类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收的活动。
注意:虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
Object obj = new Object();
ReferenceQueue queue = new ReferenceQueue();
PhantomReference pr = new PhantomReference(obj, queue);
//强引用对象滞空,保留软引用
obj = null;System.out.println(pr.get());
5.引用总结
引用类型 | 被回收时间 | 用途 | 生存时间 |
---|---|---|---|
强引用 | 从来不会 | 对象的一般状态 | JVM停止运行时 |
软引用 | 内存不足时 | 对象缓存 | 内存不足时 |
弱引用 | jvm垃圾回收时 | 对象缓存 | 垃圾回收时 |
虚引用 | 未知 | 未知 | 未知 |
1.对于强引用,平时在编写代码时会经常使用。
2.被软引用关联的对象只有在内存不足时才会被回收,而被弱引用关联的对象在JVM进行垃圾回收时总会被回收。
3.软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生
4.利用软引用和弱引用解决OOM问题:假如有一个应用需要读取大量的本地图片,如果每次读取图片都从硬盘读取,则会严重影响性能,但是如果全部加载到内存当中,又有可能造成内存溢出,此时使用软引用可以解决这个问题。
设计思路是:用一个HashMap来保存图片的路径和相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM会自动回收这些缓存图片对象所占用的空间,从而有效地避免了OOM的问题。
6.四种对象引用的差异对比
Java中4种引用的级别由高到低依次为:
强引用 > 软引用 > 弱引用 > 虚引用
垃圾回收时对比:
7.对象可及性的判断
在很多的时候,一个对象并不是从根集直接引用的,而是一个对象被其他对象引用,甚至同时被几个对象所引用,从而构成一个以根集为顶的树形结构。
在这个树形的引用链中,箭头的方向代表了引用的方向,所指向的对象是被引用对象。由图可以看出,从根集到一个对象可以由很多条路径。
比如到达对象5的路径就有① -> ⑤,③ ->⑦两条路径。由此带来了一个问题,那就是某个对象的可及性如何判断:
(1)单条引用路径可及性判断:
在这条路径中,最弱的一个引用决定对象的可及性。
(2)多条引用路径可及性判断:
几条路径中,最强的一条的引用决定对象的可及性。
比如,我们假设图2中引用①和③为强引用,⑤为软引用,⑦为弱引用,对于对象5按照这两个判断原则,路径①-⑤取最弱的引用⑤,因此该路径对对象5的引用为软引用。同样,③-⑦为弱引用。在这两条路径之间取最强的引用,于是对象5是一个软可及对象。
比较容易理解的是Java垃圾回收器会优先清理可及强度低的对象,另外两个重要的点:
- 强可达的对象一定不会被清理
- JVM保证抛出out of memory之前,清理所有的软引用对象
最后总结成一张表格:
引用类型 | 被回收时间 | 用途 | 生存时间 |
---|---|---|---|
强引用 | 从来不会 | 对象的一般状态 | JVM停止运行时 |
软引用 | 内存不足时 | 对象缓存 | 内存不足时 |
弱引用 | jvm垃圾回收时 | 对象缓存 | 垃圾回收时 |
虚引用 | 未知 | 未知 | 未知 |
8.引用队列ReferenceQueue的介绍
引用队列配合Reference的子类等使用,当引用对象所指向的对象被垃圾回收后,该Reference则被追加到引用队列的末尾.
ReferenceQueue源码分析(简要)
(1)ReferenceQueue是一个链表,这两个指针代表着头和尾
private Reference<? extends T> head = null;private Reference<? extends T> tail = null;
(2)下面看下其共有的方法
取出元素:
Reference<? extends T> ReferenceQueue#poll()
如果Reference指向的对象存在则返回null,否则返回这个Reference
public Reference<? extends T> poll() {synchronized (lock) {if (head == null)return null;return reallyPollLocked();}
}
下面是具体将Reference取出的方法:
private Reference<? extends T> reallyPollLocked() {if (head != null) {Reference<? extends T> r = head;if (head == tail) {tail = null;head = null;} else {head = head.queueNext;}// 更新链表,将sQueueNextUnenqueued这个虚引用对象加入,// 并且已经表明该Reference已经被移除了,并且取出.r.queueNext = sQueueNextUnenqueued;return r;}return null;
}
取出元素,如果队列属于空队列,那么就阻塞到其有元素为止
Reference<? extends T> ReferenceQueue#remove()
和remove()的区别是,设置一个阻塞时间
Reference<? extends T> ReferenceQueue#remove(long timeout)
具体实现:
public Reference<? extends T> remove(long timeout)throws IllegalArgumentException, InterruptedException
{if (timeout < 0) {throw new IllegalArgumentException("Negative timeout value");}synchronized (lock) {Reference<? extends T> r = reallyPollLocked();if (r != null) return r;long start = (timeout == 0) ? 0 : System.nanoTime();//阻塞的具体实现过程,以及通过时间来控制的阻塞for (;;) {lock.wait(timeout);r = reallyPollLocked();if (r != null) return r;if (timeout != 0) {long end = System.nanoTime();timeout -= (end - start) / 1000_000;if (timeout <= 0) return null;start = end;}}}
}
9.WeakHashMap的相关介绍
在Java集合中有一种特殊的Map类型即WeakHashMap
,在这种Map中存放了键对象的弱引用,当一个键对象被垃圾回收器回收时,那么相应的值对象的引用会从Map中删除.
WeakHashMap能够节约储存空间,可用来缓存那些非必须存在的数据.
而WeakHashMap是主要通过expungeStaleEntries()
这个方法来实现的,而WeakHashMap也内置了一个ReferenceQueue,来获取键对象的引用情况。这个方法,相当于遍历ReferenceQueue,然后将已经被回收的键对象对应的值对象滞空.
private void expungeStaleEntries() {for (Object x; (x = queue.poll()) != null; ) {synchronized (queue) {@SuppressWarnings("unchecked")Entry<K,V> e = (Entry<K,V>) x;int i = indexFor(e.hash, table.length);Entry<K,V> prev = table[i];Entry<K,V> p = prev;while (p != null) {Entry<K,V> next = p.next;if (p == e) {if (prev == e)table[i] = next;elseprev.next = next;// Must not null out e.next;// stale entries may be in use by a HashIterator//通过滞空,来帮助垃圾回收e.value = null; size--;break;}prev = p;p = next;}}}
}
而且需要注意的是:
expungeStaleEntries()并不是自动调用的,需要外部对WeakHashMap对象进行查询或者操作,才会进行自动释放的操作.如下我们看个例子:
下面例子是不断的增加1000*1000容量的WeakHashMap存入List中
public static void main(String[] args) throws Exception { List<WeakHashMap<byte[][], byte[][]>> maps = new ArrayList<WeakHashMap<byte[][], byte[][]>>(); for (int i = 0; i < 1000; i++) { WeakHashMap<byte[][], byte[][]> d = new WeakHashMap<byte[][], byte[][]>(); d.put(new byte[1000][1000], new byte[1000][1000]); maps.add(d); System.gc(); System.err.println(i); }
}
由于Java默认内存是64M,所以再不改变内存参数的情况下,该测试跑不了几步循环就内存溢出了。果不其然,WeakHashMap这个时候并没有自动帮我们释放不用的内存。
public static void main(String[] args) throws Exception { List<WeakHashMap<byte[][], byte[][]>> maps = new ArrayList<WeakHashMap<byte[][], byte[][]>>(); for (int i = 0; i < 1000; i++) { WeakHashMap<byte[][], byte[][]> d = new WeakHashMap<byte[][], byte[][]>(); d.put(new byte[1000][1000], new byte[1000][1000]); maps.add(d); System.gc(); System.err.println(i); for (int j = 0; j < i; j++) { System.err.println(j+ " size" + maps.get(j).size()); } }
}
而通过访问WeakHashMap的size()方法,这些就可以跑通了.
这样就能够说明了WeakHashMap并不是自动进行键值的垃圾回收操作的,而需要做对WeakHashMap的访问操作这时候才进行对键对象的垃圾回收清理.
由图可以看出,WeakHashMap中只要调用其操作方法,那么就会调用其expungeStaleEntries().
3.Java垃圾回收机制与垃圾回收算法
1.如何确定某个对象是“垃圾”
垃圾收集器的任务是回收垃圾对象所占的空间供新的对象使用,那么垃圾收集器如何确定某个对象是“垃圾”?通过什么方法判断一个对象可以被回收了。
在java中是通过引用来和对象进行关联的,也就是说如果要操作对象,必须通过引用来进行。那么很显然一个简单的办法就是通过引用计数来判断一个对象是否可以被回收。如果一个对象没有任何引用与之关联,则说明该对象基本不太可能在其他地方被使用到,那么这个对象就成为可被回收的对象了。这种方式为引用计数法
。
这种方式的特点是实现简单,而且效率较高,但是它无法解决循环引用的问题,因此在Java中并没有采用这种方式(Python采用的是引用计数法
)。
为了解决这个问题,在Java中采取了可达性分析法
。该方法的基本思想是通过一系列的“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的,不过要注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。
2.典型的垃圾收集算法
在确定了哪些垃圾可以被回收后,垃圾收集器要做的事情就是开始进行垃圾回收,但是这里面涉及到一个问题是:如何高效地进行垃圾回收。由于Java虚拟机规范并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,所以在此只讨论几种常见的垃圾收集算法的核心思想。
1.Mark-Sweep(标记-清除)算法
这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段
。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:
从图中可以很容易看出标记-清除算法实现起来比较容易,但是它主要有两个缺点:一个是效率问题,标记和清除过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2.Copying(复制)算法(针对新生代)
为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:
这种算法虽然实现简单,运行高效且不容易产生内存碎片,每次都是对其中的一块进行内存回收,内存分配时就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。
现在的商业虚拟机都采用复制收集算法来回收新生代
,有研究表明,新生代中的对象98%
是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机
默认Eden和Survivor的大小比例是8:1
,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%)
,只有10%的内存是会被“浪费”的。当然,并不能保证每次回收都只有10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。即如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。
3.Mark-Compact(标记-整理)算法(压缩法)(针对老年代)
为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:
复制收集算法在对象存活率较高时就需要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用复制收集算法。
根据老年代的特点提出了“标记-整理”算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
标记-整理的步骤:
- 1.标记阶段
- 2.整理阶段:移动存活对象,同时更新存活对象中所有指向被移动对象的指针
整理的顺序:
不同算法中,堆遍历的次数,整理的顺序,对象的迁移方式都有所不同。而整理顺序又会影响到程序的局部性。主要有以下3种顺序:
- 1.任意顺序:对象的移动方式和它们初始的对象排列及引用关系无关
任意顺序整理实现简单,且执行速度快,但任意顺序可能会将原本相邻的对象打乱到不同的高速缓存行或者是虚拟内存页中,会降低赋值器的局部性。任意顺序算法只能处理单一大小的对象,或者针对大小不同的对象需要分批处理;
- 2.线性顺序:将具有关联关系的对象排列在一起
- 3.滑动顺序:将对象“滑动”到堆的一端,从而“挤出”垃圾,可以保持对象在堆中原有的顺序
所有现代的标记-整理回收器均使用滑动整理,它不会改变对象的相对顺序,也就不会影响赋值器的空间局部性。复制式回收器甚至可以通过改变对象布局的方式,将对象与其父节点或者兄弟节点排列的更近以提高赋值器的空间局部性。
整理算法的限制,如整理过程需要2次或者3次遍历堆空间;对象头部可能需要一个额外的槽来保存迁移的信息。
部分整理算法:
- 1.双指针回收算法:实现简单且速度快,但会打乱对象的原有布局
- 2.Lisp2算法(滑动回收算法):需要在对象头用一个额外的槽来保存迁移完的地址
- 3.引线整理算法:可以在不引入额外空间开销的情况下实现滑动整理,但需要2次遍历堆,且遍历成本较高
- 4.单次遍历算法:滑动回收,实时计算出对象的转发地址而不需要额外的开销
4.Generational Collection(分代收集)算法
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将Java堆区划分为老年代(Tenured Generation)
和新生代(Young Generation)
,老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。
目前大部分垃圾收集器对于新生代都采取复制算法
,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1
的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间
和两块较小的Survivor空间
。每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。HotSpot虚拟机
默认Eden和Survivor的大小比例是8:1
,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%)
,只有10%的内存是会被“浪费”的。当然,并不能保证每次回收都只有10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。即如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。
而由于老年代的特点是每次回收都只回收少量对象,一般使用的是标记-整理算法(压缩法)
。
3.典型的垃圾收集器
垃圾收集算法是 内存回收的理论基础,而垃圾收集器就是内存回收的具体实现。下面介绍一下HotSpot(JDK 7)
虚拟机提供的几种垃圾收集器,用户可以根据自己的需求组合出各个年代使用的收集器。
1.Serial收集器和Serial Old收集器
是最基本最古老的收集器,它是一个单线程收集器,并且在它进行垃圾收集时,必须暂停所有用户线程。Serial收集器是针对新生代的收集器,采用的是Copying算法,Serial Old收集器是针对老年代的收集器,采用的是Mark-Compact算法。它的优点是实现简单高效,但是缺点是会给用户带来停顿。
2.ParNew收集器
是Serial收集器的多线程版本,使用多个线程进行垃圾收集。
3.Parallel Scavenge收集器
是一个新生代的多线程收集器(并行收集器),它在回收期间不需要暂停其他用户线程,其采用的是Copying算法,该收集器与前两个收集器有所不同,它主要是为了达到一个可控的吞吐量。
4.Parallel Old收集器
是Parallel Scavenge收集器的老年代版本(并行收集器),使用多线程和Mark-Compact算法。
5.CMS(Concurrent Mark Sweep)收集器
是一种以获取最短回收停顿时间为目标的收集器,它是一种并发收集器,采用的是Mark-Sweep算法。
6.G1收集器
是当今收集器技术发展最前沿的成果,它是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。
----------------------------------------分割-------------------------------------------
如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。下图展示了7种不同分代的收集器,如果两个收集器之间存在连线,就说明他们可以搭配使用。并没有最好的收集器这一说,我们需要选择的是对具体应用最合适的收集器。
1、Serial收集器(用于新生代)
单线程
,在进行垃圾收集时必须暂停其他所有的工作线程(“Stop the World”)。虚拟机运行在Client模式下的默认新生代收集器。简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程效率。
2、ParNew收集器(新生代)
ParNew收集器其实是Serial收集器的多线程
版本,它是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为除了Serial收集器外,目前只有它能与CMS收集器配合工作。
3、Parallel Scavenge收集器(“吞吐量优先”收集器)(新生代)
使用复制算法,并行多线程
,这些特点与ParNew一样,它的独特之处是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目的则是达到一个可控制的吞吐量(Throughput)
,即CPU用于运行用户代码的时间与CPU总消耗时间的比值,吞吐量=运行用户代码时间 /(运行用户代码时间+垃圾收集时间)
,虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,吞吐量就是99%。
- 停顿时间越短对于需要与用户交互的程序来说越好,良好的响应速度能提升用户的体验;
- 高吞吐量可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不太需要太多交互的任务。
参数设置:
-XX:MaxGCPauseMillis
控制最大垃圾收集停顿时间。(大于0的毫秒数)停顿时间缩短是以牺牲吞吐量和新生代空间换取的。(新生代调的小,吞吐量跟着小,垃圾收集时间就短,停顿就小)。-XX:GCTimeRatio
直接设置吞吐量大小,0<x<100 的整数,允许的最大GC时间=1/(1+x)。-XX:+UseAdaptiveSizePolicy
一个开关参数,开启GC自适应调节策略(GC Ergonomics),将内存管理的调优任务(新生代大小-Xmn、Eden与Survivor区的比例-XX:SurvivorRatio、晋升老年代对象年龄-XX: PretenureSizeThreshold 、等细节参数)交给虚拟机完成。这是Parallel Scavenge收集器与ParNew收集器的一个重要区别,另一个是吞吐量。
4、Serial Old收集器(老年代)
它是Serial收集器的老年代版本,单线程
,使用“标记-整理”算法。主要意义是被Client模式下的虚拟机使用
。如果在Server模式下,它还有两大用途:在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用;作为CMS 收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用。运行过程同Serial收集器。
5、Parallel Old收集器(老年代)
它是Parallel Scavenge收集器的老年代版本,多线程
,使用“标记-整理”算法。在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old收集器。工作过程如下:
6、CMS收集器(Concurrent Mark Sweep)
它是一种以获取最短回收停顿时间
为目标的收集器。优点:并发收集,低停顿
。基于“标记-清除”算法。目前很大一部分Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验,CMS收集器就非常符合这类应用的需求。运作过程较复杂,分为4个步骤:
- 初始标记(CMS initial mark):需要“Stop The World”,标记GC Roots能直接关联到的对象,速度快。
- 并发标记(CMS concurrent mark):进行GC Roots Tracing 过程
- 重新标记(CMS remark):需要“Stop The World”,修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。停顿时间:初始标记<重新标记<<并发标记
- 并发清除(CMS concurrent sweep):时间较长。
缺点:
- 对CPU资源非常敏感,面向并发设计的程序都会对CPU资源较敏感。CMS默认的回收线程数: (CPU数量+3)/4
- 无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。并发清理阶段用户程序运行产生的垃圾过了标记阶段所以无法在本次收集中清理掉,称为浮动垃圾。CMS收集器默认在老年代使用了68%的空间后被激活。若老年代增长的不是很快,可以适当调高参数
-XX:CMSInitiatingOccupancyFraction
提高触发百分比,但调得太高会容易导致“Concurrent Mode Failure”失败。 - 基于“标记-清除”算法会产生大量空间碎片。提供开关参数
-XX:+UseCMSCompactAtFullCollection
用于在“ 享受”完Full GC服务之后进行碎片整理过程,内存整理的过程是无法并发的。但是停顿时间会变长。 -XX:CMSFullGCsBeforeCompation
设置在执行多少次不压缩的Full GC后,跟来来一次带压缩的。
7、G1收集器(Garbage First)
它是当前收集器技术发展的最前沿成果。与CMS相比有两个显著改进:
- 基于“标记-整理”算法实现收集器
- 非常精确地控制停顿
G1收集器可以在几乎不牺牲吞吐量的前提下完成低停顿的内存回收,这是由于它能够极力避免全区域的垃圾收集,之前的收集器进行收集的范围都是整个新生代或老年代,而G1将整个Java堆(包括新生代、老年代)划分为多个大小固定的独立区域(Region),并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(这就是Garbage First名称的由来)。区域划分、有优先级的区域回收
,保证了G1收集器在有限的时间内可以获得最高的收集效率。
4.redis 中的数据结构有哪些
Redis 有 5 种基础数据结构,分别为:string (字符串)、list (列表)、set (集合)、hash (哈希) 和 zset (有序集合)。
Redis 的列表相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n),这点让人非常意外。 当列表弹出了最后一个元素之后,该数据结构自动被删除,内存被回收。
Redis 的哈希相当于 Java 语言里面的 HashMap,它是无序字典。内部实现结构上同 Java 的 HashMap 也是一致的,同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来。
Redis 的集合相当于 Java 语言里面的 HashSet,它内部的键值对是无序的唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值NULL。 当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。
zset 类似于 Java 的 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以给每个 value 赋予一个 score,代表这个 value 的排序权重。
1.string (字符串)
redis是使用C语言开发,但C中并没有字符串类型,只能使用指针或符数组的形式表示一个字符串,所以redis设计了一种简单动态字符串(SDS[Simple Dynamic String])作为底实现:
定义SDS对象,此对象中包含三个属性:
- len buf中已经占有的长度(表示此字符串的实际长度)
- free buf中未使用的缓冲区长度
- buf[] 实际保存字符串数据的地方
所以取字符串的长度的时间复杂度为O(1),另,buf[]中依然采用了C语言的以\0结尾可以直接使用C语言的部分标准C字符串库函数。
空间分配原则:当len小于IMB(1024*1024)时增加字符串分配空间大小为原来的2倍,当len大于等于1M时每次分配 额外多分配1M的空间。
由此可以得出以下特性:
- redis为字符分配空间的次数是小于等于字符串的长度N,而原C语言中的分配原则必为N。降低了分配次数提高了追加速度,代价就是多占用一些内存空间,且这些空间不会自动释放。
- 二进制安全的
- 高效的计算字符串长度(时间复杂度为O(1))
- 高效的追加字符串操作。
2.list (列表)
redis对键表的结构支持使得它在键值存储的世界中独树一帜,一个列表结构可以有序地存储多个字符串,拥有例如:lpush lpop rpush rpop等等操作命令。在3.2版本之前,列表是使用ziplist和linkedlist实现的,在这些老版本中,当列表对象同时满足以下两个条件时,列表对象使用ziplist编码:
- 列表对象保存的所有字符串元素的长度都小于64字节
- 列表对象保存的元素数量小于512个
当有任一条件 不满足时将会进行一次转码,使用linkedlist。
而在3.2版本之后,重新引入了一个quicklist的数据结构,列表的底层都是由quicklist实现的,它结合了ziplist和linkedlist的优点。按照原文的解释这种数据结构是【A doubly linked list of ziplists】意思就是一个由ziplist组成的双向链表。那么这两种数据结构怎么样结合的呢?
ziplist的结构
由表头和N个entry节点和压缩列表尾部标识符zlend组成的一个连续的内存块。然后通过一系列的编码规则,提高内存的利用率,主要用于存储整数和比较短的字符串。可以看出在插入和删除元素的时候,都需要对内存进行一次扩展或缩减,还要进行部分数据的移动操作,这样会造成更新效率低下的情况。
这篇文章对ziplist的结构讲的还是比较详细的:
https://blog.csdn.net/yellowriver007/article/details/79021049
linkedlist的结构
意思为一个双向链表,和普通的链表定义相同,每个entry包含向前向后的指针,当插入或删除元素的时候,只需要对此元素前后指针操作即可。所以插入和删除效率很高。但查询的效率却是O(n)[n为元素的个数]。
了解了上面的这两种数据结构,我们再来看看上面说的“ziplist组成的双向链表”是什么意思?实际上,它整体宏观上就是一个链表结构,只不过每个节点都是以压缩列表ziplist的结构保存着数据,而每个ziplist又可以包含多个entry。也可以说一个quicklist节点保存的是一片数据,而不是一个数据。总结:
- 整体上quicklist就是一个双向链表结构,和普通的链表操作一样,插入删除效率很高,但查询的效率却是O(n)。不过,这样的链表访问两端的元素的时间复杂度却是O(1)。所以,对list的操作多数都是poll和push。
- 每个quicklist节点就是一个ziplist,具备压缩列表的特性。
3.set (集合)
redis的集合和列表都可以存储多个字符串,它们之间的不同在于,列表可以存储多个相同的字符串,而集合则通过使用散列表(hashtable)来保证自已存储的每个字符串都是各不相同的(这些散列表只有键,但没有与键相关联的值),redis中的集合是无序的。还可能存在另一种集合,那就是intset,它是用于存储整数的有序集合,里面存放同一类型的整数。共有三种整数:int16_t、int32_t、int64_t。查找的时间复杂度为O(logN),但是插入的时候,有可能会涉及到升级(比如:原来是int16_t的集合,当插入int32_t的整数的时候就会为每个元素升级为int32_t)这时候会对内存重新分配,所以此时的时间复杂度就是O(N)级别的了。注意:intset只支持升级不支持降级操作。
intset在redis.conf中也有一个配置参数set-max-intset-entries默认值为512。表示如果entry的个数小于此值,则可以编码成REDIS_ENCODING_INTSET类型存储,节约内存。否则采用dict的形式存储。
4.hash (哈希)
hash底层的数据结构实现有两种:
一种是ziplist,上面已经提到过。当存储的数据超过配置的阀值时就是转用hashtable的结构。这种转换比较消耗性能,所以应该尽量避免这种转换操作。同时满足以下两个条件时才会使用这种结构:
- 当键的个数小于hash-max-ziplist-entries(默认512)
- 当所有值都小于hash-max-ziplist-value(默认64)
另一种就是hashtable。这种结构的时间复杂度为O(1),但是会消耗比较多的内存空间。
5.zset (有序集合)
有序集合和散列一样,都用于存储键值对:有序集合的键被称为成员(member),每个成员都是各不相同的。有序集合的值则被称为分值(score),分值必须为浮点数。有序集合是redis里面唯一一个既可以根据成员访问元素(这一点和散列一样),又可以根据分值以及分值的排列顺序访问元素的结构。它的存储方式也有两种:
- ziplist结构。
与上面的hash中的ziplist类似,member和score顺序存放并按score的顺序排列
- 另一种是skiplist与dict的结合。
skiplist是一种跳跃表结构,用于有序集合中快速查找,大多数情况下它的效率与平衡树差不多,但比平衡树实现简单。redis的作者对普通的跳跃表进行了修改,包括添加span\tail\backward指针、score的值可重复这些设计,从而实现排序功能和反向遍历的功能。
一般跳跃表的实现,主要包含以下几个部分:
- 表头(head):指向头节点
- 表尾(tail):指向尾节点
- 节点(node):实际保存的元素节点,每个节点可以有多层,层数是在创建此节点的时候随机生成的一个数值,而且每一层都是一个指向后面某个节点的指针。
- 层(level):目前表内节点的最大层数
- 长度(length):节点的数量。
跳跃表的遍历总是从高层开始,然后随着元素值范围的缩小,慢慢降低到低层。
跳跃表的实现原理可以参考:https://blog.csdn.net/Acceptedxukai/article/details/17333673
前面也说了,有序列表是使用skiplist和dict结合实现的,skiplist用来保障有序性和访问查找性能,dict就用来存储元素信息,并且dict的访问时间复杂度为O(1)。
5.为什么说redis能够快速执行
i. 绝大部分请求是纯粹的内存操作(非常快速)
ii. 采用单线程,避免了不必要的上下文切换和竞争条件
iii. 非阻塞IO - IO多路复用
6.Redis 的持久化机制
1.RDB持久化
RDB持久化即通过创建快照(压缩的二进制文件)的方式进行持久化,保存某个时间点的全量数据。RDB持久化是Redis默认的持久化方式。RDB持久化的触发包括手动触发与自动触发两种方式。
2.AOF持久化
AOF(Append-Only-File)持久化即记录所有变更数据库状态的指令,以append的形式追加保存到AOF文件中。在服务器下次启动时,就可以通过载入和执行AOF文件中保存的命令,来还原服务器关闭前的数据库状态。
3.RDB、AOF混合持久化
Redis从4.0版开始支持RDB与AOF的混合持久化方案。首先由RDB定期完成内存快照的备份,然后再由AOF完成两次RDB之间的数据备份,由这两部分共同构成持久化文件。该方案的优点是充分利用了RDB加载快、备份文件小及AOF尽可能不丢数据的特性。缺点是兼容性差,一旦开启了混合持久化,在4.0之前的版本都不识别该持久化文件,同时由于前部分是RDB格式,阅读性较低。
Redis 持久化方案的建议
如果Redis只是用来做缓存服务器,比如数据库查询数据后缓存,那可以不用考虑持久化,因为缓存服务失效还能再从数据库获取恢复。
如果你要想提供很高的数据保障性,那么建议你同时使用两种持久化方式。如果你可以接受灾难带来的几分钟的数据丢失,那么可以仅使用RDB。
通常的设计思路是利用主从复制机制来弥补持久化时性能上的影响。即Master上RDB、AOF都不做,保证Master的读写性能,而Slave上则同时开启RDB和AOF(或4.0以上版本的混合持久化方式)来进行持久化,保证数据的安全性。
Redis 持久化方案的优缺点
1.RDB持久化
优点:RDB文件紧凑,体积小,网络传输快,适合全量复制;恢复速度比AOF快很多。当然,与AOF相比,RDB最重要的优点之一是对性能的影响相对较小。
缺点:RDB文件的致命缺点在于其数据快照的持久化方式决定了必然做不到实时持久化,而在数据越来越重要的今天,数据的大量丢失很多时候是无法接受的,因此AOF持久化成为主流。此外,RDB文件需要满足特定格式,兼容性差(如老版本的Redis不兼容新版本的RDB文件)。
2.AOF持久化
与RDB持久化相对应,AOF的优点在于支持秒级持久化、兼容性好,缺点是文件大、恢复速度慢、对性能影响大。
7.Redis缓存穿透、缓存击穿、缓存雪崩解决方案
1.缓存穿透
指查询一个一定不存在的数据,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。
解决方案:
i. 查询返回的数据为空,仍把这个空结果进行缓存,但过期时间会比较短
ii. 布隆过滤器:将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对 DB 的查询。
2.缓存击穿
对于设置了过期时间的 key,缓存在某个时间点过期的时候,恰好这时间点对这个 Key 有大量的并发请求过来,这些请求发现缓存过期一般都会从后端 DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把 DB 压垮。
解决方案:
i. 使用互斥锁:当缓存失效时,不立即去 load db,先使用如 Redis 的 setnx 去设置一个互斥锁,当操作成功返回时再进行 load db 的操作并回设缓存,否则重试 get 缓存的方法。
ii 永远不过期:物理不过期,但逻辑过期(后台异步线程去刷新)。
3.缓存雪崩
设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到 DB, DB 瞬时压力过重雪崩。与缓存击穿的区别:雪崩是很多 key,击穿是某一个key 缓存。
解决方案:
将缓存失效时间分散开,比如可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
8.Redis 的集群模式
主从复制
当从数据库启动时,会向主数据库发送sync命令,主数据库接收到sync后开始在后台保存快照rdb,在保存快照期间收到的命令缓存起来,当快照完成时,主数据库会将快照和缓存的命令一块发送给从**。复制初始化结束。 之后,主每收到1个命令就同步发送给从。 当出现断开重连后,2.8之后的版本会将断线期间的命令传给重数据库。
主从复制是乐观复制,当客户端发送写执行给主,主执行完立即将结果返回客户端,并异步的把命令发送给从,从而不影响性能。也可以设置至少同步给多少个从主才可写。 无硬盘复制:如果硬盘效率低将会影响复制性能,2.8之后可以设置无硬盘复制,repl-diskless-sync yes
哨兵模式
哨兵的作用:
1、监控redis主、从数据库是否正常运行
2、主出现故障自动将从数据库转换为主数据库。
哨兵的核心知识:
1、哨兵至少需要 3 个实例,来保证自己的健壮性。
2、哨兵 + redis 主从的部署架构,是不保证数据零丢失的,只能保证 redis 集群的高可用性。
3、对于哨兵 + redis 主从这种复杂的部署架构,尽量在测试环境和生产环境,都进行充足的测试和演练。
4、配置哨兵监控一个系统时,只需要配置其监控主数据库即可,哨兵会自动发现所有复制该主数据库的从数据库。
9.Redis分布式锁
使用过Redis分布式锁么,它是什么回事?
先拿setnx来争抢锁,抢到之后,再用expire给锁加一个过期时间防止锁忘记了释放。
如果在setnx之后执行expire之前进程意外crash或者要重启维护了,这个锁就永远得不到释放了,使用set指令把setnx和expire合成一条指令来用
红锁
用Redis中的多个master实例,来获取锁,只有大多数实例获取到了锁,才算是获取成功。具体的红锁算法分为以下五步:
- 1.获取当前的时间(单位是毫秒)。
- 2.使用相同的key和随机值在N个节点上请求锁。这里获取锁的尝试时间要远远小于锁的超时时间,防止某个masterDown了,我们还在不断的获取锁,而被阻塞过长的时间。
- 3.只有在大多数节点上获取到了锁,而且总的获取时间小于锁的超时时间的情况下,认为锁获取成功了。
- 4.如果锁获取成功了,锁的超时时间就是最初的锁超时时间进去获取锁的总耗时时间。
- 5.如果锁获取失败了,不管是因为获取成功的节点的数目没有过半,还是因为获取锁的耗时超过了锁的释放时间,都会将已经设置了key的master上的key删除。
10.Redis内存淘汰机制
redis 内存淘汰机制有以下几个:
- noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错,这个一般没人用吧,实在是太恶心了。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key,这个一般没人用吧,为啥要随机,肯定是把最近最少使用的 key 给干掉啊。
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key(这个一般不太合适)。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。
11.Redis 和 Mysql 的数据不一致怎么办
采用延时双删策略
- 第一个删redis。如果先写了库,在删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。
- 第二个删redis。如果删除了缓存Redis,还没有来得及写库MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。
public void use(String key, Object data){redis.delKey(key);db.updateData(data);Thread.sleep(800);redis.delKey(key);
}
(1)先淘汰缓存
(2)再写数据库(这两步和原来一样)
(3)休眠800ms,再次淘汰缓存
这么做,可以将800ms内所造成的缓存脏数据,再次删除。
12.Redis常见性能问题和解决方案
i. Master最好不要做任何持久化工作,如RDB内存快照和AOF日志文件;(Master写内存快照,save命令调度rdbSave函数,会阻塞主线程的工作,当快照比较大时对性能影响是非常大的,会间断性暂停服务,所以Master最好不要写内存快照;AOF文件过大会影响Master重启的恢复速度)
ii. 如果数据比较重要,某个Slave开启AOF备份数据,策略设置为每秒同步一次
iii. 为了主从复制的速度和连接的稳定性,Master和Slave最好在同一个局域网内
iv. 尽量避免在压力很大的主库上增加从库
v. 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1 <- Slave2 <- Slave3…;这样的结构方便解决单点故障问题,实现Slave对Master的替换。如果Master挂了,可以立刻启用Slave1做Master,其他不变。
13.mySQL里有2000w数据,redis中只存20w的数据,如何保证redis中的数据都是热点数据
volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
no-enviction(驱逐):禁止驱逐数据
注意这里的6种机制,volatile和allkeys规定了是对已设置过期时间的数据集淘汰数据还是从全部数据集淘汰数据,后面的lru、ttl以及random是三种不同的淘汰策略,再加上一种no-enviction永不回收的策略。
使用策略规则:
1、如果数据呈现幂律分布,也就是一部分数据访问频率高,一部分数据访问频率低,则使用allkeys-lru
2、如果数据呈现平等分布,也就是所有的数据访问频率都相同,则使用allkeys-random
14.Redis里面有1亿个key,其中有10w个key是以某个固定的已知的前缀开头的,如何将它们全部找出来?
使用keys指令可以扫出指定模式的key列表。对方接着追问:如果这个redis正在给线上的业务提供服务,那使用keys指令会有什么问题?这个时候你要回答redis关键的一个特性:redis的单线程的。keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。
15.项目中有没有用Redis事务
采用的是Redis Cluster集群架构,不同的key是有可能分配在不同的Redis节点上的,在这种情况下Redis的事务机制是不生效的。其次,Redis事务不支持回滚操作,所以基本不用!
16.布隆过滤器
在程序的世界中,布隆过滤器是程序员的一把利器,利用它可以快速地解决项目中一些比较棘手的问题。如网页 URL 去重、垃圾邮件识别、大集合中重复元素的判断和缓存穿透等问题。
布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。
一、布隆过滤器简介
当你往简单数组或列表中插入新数据时,将不会根据插入项的值来确定该插入项的索引值。这意味着新插入项的索引值与数据值之间没有直接关系。这样的话,当你需要在数组或列表中搜索相应值的时候,你必须遍历已有的集合。若集合中存在大量的数据,就会影响数据查找的效率。
针对这个问题,你可以考虑使用哈希表。利用哈希表你可以通过对 “值” 进行哈希处理来获得该值对应的键或索引值,然后把该值存放到列表中对应的索引位置。这意味着索引值是由插入项的值所确定的,当你需要判断列表中是否存在该值时,只需要对值进行哈希处理并在相应的索引位置进行搜索即可,这时的搜索速度是非常快的。
根据定义,布隆过滤器可以检查值是 “可能在集合中” 还是 “绝对不在集合中”。“可能” 表示有一定的概率,也就是说可能存在一定为误判率。那为什么会存在误判呢?下面我们来分析一下具体的原因。
布隆过滤器(Bloom Filter)本质上是由长度为 m 的位向量或位列表(仅包含 0 或 1 位值的列表)组成,最初所有的值均设置为 0,如下图所示。
为了将数据项添加到布隆过滤器中,我们会提供 K 个不同的哈希函数,并将结果位置上对应位的值置为 “1”。在前面所提到的哈希表中,我们使用的是单个哈希函数,因此只能输出单个索引值。而对于布隆过滤器来说,我们将使用多个哈希函数,这将会产生多个索引值。
如上图所示,当输入 “semlinker” 时,预设的 3 个哈希函数将输出 2、4、6,我们把相应位置 1。假设另一个输入 ”kakuqo“,哈希函数输出 3、4 和 7。你可能已经注意到,索引位 4 已经被先前的 “semlinker” 标记了。此时,我们已经使用 “semlinker” 和 ”kakuqo“ 两个输入值,填充了位向量。当前位向量的标记状态为:
当对值进行搜索时,与哈希表类似,我们将使用 3 个哈希函数对 ”搜索的值“ 进行哈希运算,并查看其生成的索引值。假设,当我们搜索 ”fullstack“ 时,3 个哈希函数输出的 3 个索引值分别是 2、3 和 7:
从上图可以看出,相应的索引位都被置为 1,这意味着我们可以说 ”fullstack“ 可能已经插入到集合中。事实上这是误报的情形,产生的原因是由于哈希碰撞导致的巧合而将不同的元素存储在相同的比特位上。幸运的是,布隆过滤器有一个可预测的误判率(FPP):
极端情况下,当布隆过滤器没有空闲空间时(满),每一次查询都会返回 true 。这也就意味着 m 的选择取决于期望预计添加元素的数量 n ,并且 m 需要远远大于 n 。
实际情况中,布隆过滤器的长度 m 可以根据给定的误判率(FFP)的和期望添加的元素个数 n 的通过如下公式计算:
了解完上述的内容之后,我们可以得出一个结论,当我们搜索一个值的时候,若该值经过 K 个哈希函数运算后的任何一个索引位为 ”0“,那么该值肯定不在集合中。但如果所有哈希索引值均为 ”1“,则只能说该搜索的值可能存在集合中。
二、布隆过滤器应用
在实际工作中,布隆过滤器常见的应用场景如下:
- 网页爬虫对 URL 去重,避免爬取相同的 URL 地址;
- 反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱;
- Google Chrome 使用布隆过滤器识别恶意 URL;
- Medium 使用布隆过滤器避免推荐给用户已经读过的文章;
- Google BigTable,Apache HBbase 和 Apache Cassandra 使用布隆过滤器减少对不存在的行和列的查找。 除了上述的应用场景之外,布隆过滤器还有一个应用场景就是解决缓存穿透的问题。所谓的缓存穿透就是服务调用方每次都是查询不在缓存中的数据,这样每次服务调用都会到数据库中进行查询,如果这类请求比较多的话,就会导致数据库压力增大,这样缓存就失去了意义。
利用布隆过滤器我们可以预先把数据查询的主键,比如用户 ID 或文章 ID 缓存到过滤器中。当根据 ID 进行数据查询的时候,我们先判断该 ID 是否存在,若存在的话,则进行下一步处理。若不存在的话,直接返回,这样就不会触发后续的数据库查询。需要注意的是缓存穿透不能完全解决,我们只能将其控制在一个可以容忍的范围内。
三、布隆过滤器实战
布隆过滤器有很多实现和优化,由 Google 开发著名的 Guava 库就提供了布隆过滤器(Bloom Filter)的实现。在基于 Maven 的 Java 项目中要使用 Guava 提供的布隆过滤器,只需要引入以下坐标:
<dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>28.0-jre</version>
</dependency>
在导入 Guava 库后,我们新建一个 BloomFilterDemo 类,在 main 方法中我们通过 BloomFilter.create 方法来创建一个布隆过滤器,接着我们初始化 1 百万条数据到过滤器中,然后在原有的基础上增加 10000 条数据并判断这些数据是否存在布隆过滤器中:
import com.google.common.base.Charsets;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;public class BloomFilterDemo {public static void main(String[] args) {int total = 1000000; // 总数量BloomFilter<CharSequence> bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), total);// 初始化 1000000 条数据到过滤器中for (int i = 0; i < total; i++) {bf.put("" + i);}// 判断值是否存在过滤器中int count = 0;for (int i = 0; i < total + 10000; i++) {if (bf.mightContain("" + i)) {count++;}}System.out.println("已匹配数量 " + count);}
}
当以上代码运行后,控制台会输出以下结果:
已匹配数量 1000309
很明显以上的输出结果已经出现了误报,因为相比预期的结果多了 309 个元素,误判率为:
309/(1000000 + 10000) * 100 ≈ 0.030594059405940593
如果要提高匹配精度的话,我们可以在创建布隆过滤器的时候设置误判率 fpp:
BloomFilter<CharSequence> bf = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), total, 0.0002
);
在 BloomFilter 内部,误判率 fpp 的默认值是 0.03:
// com/google/common/hash/BloomFilter.class
public static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions) {return create(funnel, expectedInsertions, 0.03D);
}
在重新设置误判率为 0.0002 之后,我们重新运行程序,这时控制台会输出以下结果:
已匹配数量 1000003
通过观察以上的结果,可知误判率 fpp 的值越小,匹配的精度越高。当减少误判率 fpp 的值,需要的存储空间也越大,所以在实际使用过程中需要在误判率和存储空间之间做个权衡。
四、总结
本文主要介绍的布隆过滤器的概念和常见的应用场合,在实战部分我们演示了 Google 著名的 Guava 库所提供布隆过滤器(Bloom Filter)的基本使用,同时我们也介绍了布隆过滤器出现误报的原因及如何提高判断准确性。最后为了便于大家理解布隆过滤器,我们介绍了一个简易版的布隆过滤器 SimpleBloomFilter。
17.Redis跳表原理
18.一致性hash原理与实现
前言
互联网公司中,绝大部分都没有马爸爸系列的公司那样财大气粗,他们即没有强劲的服务器、也没有钱去购买昂贵的海量数据库。那他们是怎么应对大数据量高并发的业务场景的呢?
这个和当前的开源技术、海量数据架构都有着不可分割的关系。比如通过mysql、nginx等开源软件,通过架构和低成本的服务器搭建千万级别的用户访问系统。
怎么样搭建一个好的系统架构,这个话题我们能聊上个七天七夜。这里我主要结合Redis集群来讲一下一致性Hash的相关问题。
Redis集群的使用
我们在使用Redis的过程中,为了保证Redis的高可用,我们一般会对Redis做主从复制,组成Master-Master
或者Master-Slave
的形式,进行数据的读写分离,如下图1-1所示:
当缓存数据量超过一定的数量时,我们就要对Redis集群做分库分表的操作。
来个栗子,我们有一个电商平台,需要使用Redis存储商品的图片资源,存储的格式为键值对,key值为图片名称,Value为该图片所在的文件服务器的路径,我们需要根据文件名,查找到文件所在的文件服务器上的路径,我们的图片数量大概在3000w左右,按照我们的规则进行分库,规则就是随机分配的,我们以每台服务器存500w的数量,部署12台缓存服务器,并且进行主从复制,架构图如下图1-2所示:
图1-2:Redis分库分表
由于我们定义的规则是随机的,所以我们的数据有可能存储在任何一组Redis中,比如我们需要查询"product.png"的图片,由于规则的随机性,我们需要遍历所有Redis服务器,才能查询得到。这样的结果显然不是我们所需要的。所以我们会想到按某一个字段值进行Hash值、取模。所以我们就看看使用Hash的方式是怎么进行的。
使用Hash的Redis集群
如果我们使用Hash的方式,每一张图片在进行分库的时候都可以定位到特定的服务器,示意图如图1-3所示:
图1-3:使用Hash方式的命中缓存
从上图中,我们需要查询的是图product.png
,由于我们有6台主服务器,所以计算的公式为:hash(product.png) % 6 = 5
, 我们就可以定位到是5号主从,这们就省去了遍历所有服务器的时间,从而大大提升了性能。
使用Hash时遇到的问题
在上述hash取模的过程中,我们虽然不需要对所有Redis服务器进行遍历而提升了性能。但是,使用Hash算法缓存时会出现一些问题,Redis服务器变动时,所有缓存的位置都会发生改变
。
比如,现在我们的Redis缓存服务器增加到了8台,我们计算的公式从hash(product.png) % 6 = 5
变成了hash(product.png) % 8 = ?
结果肯定不是原来的5了。
再者,6台的服务器集群中,当某个主从群出现故障时,无法进行缓存,那我们需要把故障机器移除,所以取模数又会从6变成了5。我们计算的公式也会变化。
由于上面hash算法是使用取模来进行缓存的,为了规避上述情况,Hash一致性算法就诞生了~~
一致性Hash算法原理
一致性Hash算法也是使用取模的方法,不过,上述的取模方法是对服务器的数量进行取模,而一致性的Hash算法是对2的32方
取模。即,一致性Hash算法将整个Hash空间组织成一个虚拟的圆环,Hash函数的值空间为0 ~ 2^32 - 1(一个32位无符号整型)
,整个哈希环如下:
图1-4:Hash圆环
整个圆环以顺时针方向组织
,圆环正上方的点代表0,0点右侧的第一个点代表1,以此类推。
第二步,我们将各个服务器使用Hash进行一个哈希,具体可以选择服务器的IP或主机名作为关键字进行哈希,这样每台服务器就确定在了哈希环的一个位置上,比如我们有三台机器,使用IP地址哈希后在环空间的位置如图1-4所示:
现在,我们使用以下算法定位数据访问到相应的服务器:
将数据Key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针查找,遇到的服务器就是其应该定位到的服务器。
例如,现在有ObjectA,ObjectB,ObjectC三个数据对象,经过哈希计算后,在环空间上的位置如下:
根据一致性算法,Object -> NodeA,ObjectB -> NodeB, ObjectC -> NodeC
一致性Hash算法的容错性和可扩展性
现在,假设我们的Node C宕机了,我们从图中可以看到,A、B不会受到影响,只有Object C对象被重新定位到Node A。所以我们发现,在一致性Hash算法中,如果一台服务器不可用,受影响的数据仅仅是此服务器到其环空间前一台服务器之间的数据(这里为Node C到Node B之间的数据),其他不会受到影响。如图1-6所示:
图1-6:C节点宕机情况,数据移到节点A上
另外一种情况,现在我们系统增加了一台服务器Node X,如图1-7所示:
图1-7:增加新的服务器节点X
此时对象ObjectA、ObjectB没有受到影响,只有Object C重新定位到了新的节点X上。
如上所述:
一致性Hash算法对于节点的增减都只需重定位环空间中的一小部分数据,有很好的容错性和可扩展性。
数据倾斜问题
在一致性Hash算法服务节点太少的情况下,容易因为节点分布不均匀面造成数据倾斜(被缓存的对象大部分缓存在某一台服务器上)问题
,如图1-8特例:
图1-8:数据倾斜
这时我们发现有大量数据集中在节点A上,而节点B只有少量数据。为了解决数据倾斜问题,一致性Hash算法引入了虚拟节点机制
,即对每一个服务器节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。
具体操作可以为服务器IP或主机名后加入编号来实现,实现如图1-9所示:
图1-9:增加虚拟节点情况
数据定位算法不变,只需要增加一步:虚拟节点到实际点的映射。
所以加入虚拟节点之后,即使在服务节点很少的情况下,也能做到数据的均匀分布。
具体实现
算法接口类
public interface IHashService {Long hash(String key);
}
算法接口实现类
public class HashService implements IHashService {/*** MurMurHash算法,性能高,碰撞率低** @param key String* @return Long*/public Long hash(String key) {ByteBuffer buf = ByteBuffer.wrap(key.getBytes());int seed = 0x1234ABCD;ByteOrder byteOrder = buf.order();buf.order(ByteOrder.LITTLE_ENDIAN);long m = 0xc6a4a7935bd1e995L;int r = 47;long h = seed ^ (buf.remaining() * m);long k;while (buf.remaining() >= 8) {k = buf.getLong();k *= m;k ^= k >>> r;k *= m;h ^= k;h *= m;}if (buf.remaining() > 0) {ByteBuffer finish = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);finish.put(buf).rewind();h ^= finish.getLong();h *= m;}h ^= h >>> r;h *= m;h ^= h >>> r;buf.order(byteOrder);return h;}
}
模拟机器节点
public class Node<T> {private String ip;private String name;public Node(String ip, String name) {this.ip = ip;this.name = name;}public String getIp() {return ip;}public void setIp(String ip) {this.ip = ip;}public String getName() {return name;}public void setName(String name) {this.name = name;}/*** 使用IP当做hash的Key** @return String*/@Overridepublic String toString() {return ip;}
}
一致性Hash操作
public class ConsistentHash<T> {// Hash函数接口private final IHashService iHashService;// 每个机器节点关联的虚拟节点数量private final int numberOfReplicas;// 环形虚拟节点private final SortedMap<Long, T> circle = new TreeMap<Long, T>();public ConsistentHash(IHashService iHashService, int numberOfReplicas, Collection<T> nodes) {this.iHashService = iHashService;this.numberOfReplicas = numberOfReplicas;for (T node : nodes) {add(node);}}/*** 增加真实机器节点** @param node T*/public void add(T node) {for (int i = 0; i < this.numberOfReplicas; i++) {circle.put(this.iHashService.hash(node.toString() + i), node);}}/*** 删除真实机器节点** @param node T*/public void remove(T node) {for (int i = 0; i < this.numberOfReplicas; i++) {circle.remove(this.iHashService.hash(node.toString() + i));}}public T get(String key) {if (circle.isEmpty()) return null;long hash = iHashService.hash(key);// 沿环的顺时针找到一个虚拟节点if (!circle.containsKey(hash)) {SortedMap<Long, T> tailMap = circle.tailMap(hash);hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();}return circle.get(hash);}
}
测试类
public class TestHashCircle {// 机器节点IP前缀private static final String IP_PREFIX = "192.168.0.";public static void main(String[] args) {// 每台真实机器节点上保存的记录条数Map<String, Integer> map = new HashMap<String, Integer>();// 真实机器节点, 模拟10台List<Node<String>> nodes = new ArrayList<Node<String>>();for (int i = 1; i <= 10; i++) {map.put(IP_PREFIX + i, 0); // 初始化记录Node<String> node = new Node<String>(IP_PREFIX + i, "node" + i);nodes.add(node);}IHashService iHashService = new HashService();// 每台真实机器引入100个虚拟节点ConsistentHash<Node<String>> consistentHash = new ConsistentHash<Node<String>>(iHashService, 500, nodes);// 将5000条记录尽可能均匀的存储到10台机器节点上for (int i = 0; i < 5000; i++) {// 产生随机一个字符串当做一条记录,可以是其它更复杂的业务对象,比如随机字符串相当于对象的业务唯一标识String data = UUID.randomUUID().toString() + i;// 通过记录找到真实机器节点Node<String> node = consistentHash.get(data);// 再这里可以能过其它工具将记录存储真实机器节点上,比如MemoryCache等// ...// 每台真实机器节点上保存的记录条数加1map.put(node.getIp(), map.get(node.getIp()) + 1);}// 打印每台真实机器节点保存的记录条数for (int i = 1; i <= 10; i++) {System.out.println(IP_PREFIX + i + "节点记录条数:" + map.get(IP_PREFIX + i));}}
}
运行结果如下:
每台机器映射的虚拟节点越多,则分布的越均匀~~~
感兴趣的同学可以拷贝上面的代码运行尝试一下。
作者:oneape15
链接:https://www.jianshu.com/p/528ce5cd7e8f
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
19.==和eauals()的区别
==:
基本类型比较值:只要两个变量的值相等,即为true
引用类型比较引用(是否指向同一个对象):只有指向同一个对象时,才为true
注意:符号两边的数据类型必须兼容(可自动转换的基本数据类型除外),否则会编译出错
代码演示:
@Test
public void test(){byte byte1 = 2;long long1 = 2l;// byte类型可以自动转换为long类型// 转换规则:short(char、byte)→int→long→float→doubleSystem.out.println(byte1 == long1); // true// 每new一个对象都会分配不一样的内存地址值。(存放在堆中)Object object1 = new Object();Object object2 = new Object();Object object3 = object1;System.out.println(object1 == object2);// falseSystem.out.println(object1 == object3);// true
}
equals():
只能比较引用类型,不能比较基本数据类型
,Object类中的equals方法作用与==相同,比较的是是否指向同一个对象,Object类中的equals方法如下:
public boolean equals(Object obj) {return (this == obj);
}
所有的类都继承了Object类,也就获得了equals方法,equals方法可以重写
可以重写equals方法,用来比较两个对象的“内容”是否相等
重写equals()方法的原则
l 对称性:如果x.equals(y)
返回是true
,那么y.equals(x)
也应该返回是true
。
l 自反性:x.equals(x)
必须返回是true
。
l 传递性:如果x.equals(y)
返回是true
,而且y.equals(z)
返回是true
,那么z.equals(x)
也应该返回是true
。
l 一致性:如果x.equals(y)
返回是true
,只要x
和y
内容一直不变,不管你重复x.equals(y)
多少次,返回都是true
。
l 任何情况下,x.equals(null)
,永远返回是false
;x.equals(和x不同类型的对象)
永远返回是false
。
代码演示:
@Test
public void test2(){//每new一个对象都会分配不一样的内存地址值。(存放在堆中)Object object1 = new Object();Object object2 = new Object();Object object3 = object1;System.out.println(object1.equals(object2));// falseSystem.out.println(object1.equals(object3));// true
}
我们发现equals方法与==是一样的,比较的是是否指向同一个对象。但是需要注意的是:对类File、String、Date以及包装类来说,是比较类型以及内容而不考虑引用的是否是同一个对象。因为他们重写了equals方法
。我们点进去String的源码,
编写代码测试,
@Test
public void test3(){//每new一个对象都会分配不一样的内存地址值。(存放在堆中)String s1 = "hello";String s2 = new String("hello");System.out.println(s1 == s2); // falseSystem.out.println(s1.equals(s2)); // true
}
首先我们需要知道:通过new关键字创建的字符串(字符串非常量对象)是存储在堆中的,而通过=为字符串赋值的操作,字符串(字符串常量)存储在常量池中
而==比较的是引用的地址,所以s1和s2不相等,又因为String类重写了equals方法,比较的是类型以及内容而不考虑引用的是否是同一个对象,所以s1.equals(s2)
20.静态代码块、构造代码块、构造函数、普通代码块的执行顺序
1.静态代码块
2.构造代码块
3.构造函数与普通代码块
4.执行顺序
静态代码块>构造代码块>构造函数>普通代码块
Java程序初始化工作可以在许多不同的代码块中来完成,它们的执行顺序如下:
父类的静态变量、父类的静态代码块、子类的静态变量、子类的静态代码块、
父类的非静态变量、父类的非静态代码块(构造代码块)、父类的构造函数、
子类的非静态变量、子类的非静态代码块(构造代码块)、子类的构造函数
代码示例:
父类(基类、超类):
public class Father {// 父类静态代码块static {System.out.println("我是父类静态代码块");}// 父类构造代码块{System.out.println("我是父类构造代码块");}// 父类构造函数public Father() {System.out.println("我是父类构造函数");}// 父类的普通代码块public void method(){System.out.println("我是父类普通代码块");}
}
子类(派生类):
public class Song extends Father{public static void main(String[] args) {Song song = new Song();song.method();}// 子类静态代码块static {System.out.println("我是子类静态代码块");}// 子类构造代码块{System.out.println("我是子类构造代码块");}// 子类构造函数public Song() {//super(); // 调用父类无参构造器,不写也默认存在System.out.println("我是子类构造函数");}// 子类普通代码块@Overridepublic void method() {System.out.println("我是子类普通代码块");}
}
运行main方法,输出如下:
我是父类静态代码块
我是子类静态代码块
我是父类构造代码块
我是父类构造函数
我是子类构造代码块
我是子类构造函数
我是子类普通代码块
21.this和super
this
this 可以调用类的属性、方法和构造器
Ø 它在方法内部使用,即这个方法所属对象的引用;
Ø 它在构造器内部使用,表示该构造器正在初始化的对象。
Ø 当在方法内需要用到调用该方法的对象时,就用this。具体的:我们可以用this来区分属性和局部变量(当形参与成员变量同名时,必须通过this来指定调用成员变量)。比如:this.name = name;
Ø 使用this访问属性或方法时,如果在本类中未找到,会从父类中查找
Ø 可以在类的构造器中使用this(形参列表)
的方式,调用本类中重载的其他的构造器!
Ø 明确:构造器中不能通过this(形参列表)
的方式调用自身构造器
Ø 如果一个类中声明了n
个构造器,则最多有 n - 1
个构造器中使用了this(形参列表)
Ø this(形参列表)
必须声明在类的构造器的首行
!使用this()
必须放在构造器的首行!
Ø 在类的一个构造器中,最多只能声明一个this(形参列表)
Ø 使用在类中,可以用来修饰属性、方法、构造器
Ø 表示当前对象或者是当前正在创建的对象
Ø 当形参与成员变量重名时,如果在方法内部需要使用成员变量,必须添加this
来表明该变量是类成员
Ø 在任意方法内,如果使用当前类的成员变量或成员方法可以在其前面添加this
,增强程序的阅读性
Ø 在构造器中使用this(形参列表)
显式的调用本类中重载的其它的构造器
Ø 它在方法内部使用,即这个方法所属对象的引用;
Ø 它在构造器内部使用,表示该构造器正在初始化的对象。
Ø this
表示当前对象,可以调用类的属性、方法和构造器
Ø 使用this
调用本类中其他的构造器,保证至少有一个构造器是不用this的。
Ø this是Java的一个关键字,表示某个对象。this可以出现在实例方法和构造方法中,但不可以出现在类方法中
。
Ø this关键字出现在类的构造方法中时,代表使用该构造方法所创建的对象.
Ø 当this关键字出现实例方法中时,this就代表正在调用该方法的当前对象。
super
在Java类中使用super来调用父类中的指定操作:
Ø super可用于访问父类中定义的属性
Ø super可用于调用父类中定义的成员方法
Ø super可用于在子类构造方法中调用父类的构造器
使用
1.super,相较于关键字this,可以修饰属性、方法、构造器
2.super修饰属性、方法:在子类的方法、构造器中,通过super.属性
或者super.方法
的形式,显式的调用父类的指定属性或方法。尤其是,当子类与父类有同名的属性、或方法时,调用父类中的结构的话,一定要用super
3.通过super(形参列表)
,显式的在子类的构造器中,调用父类指定的构造器!
4.用super操作被隐藏
的成员变量和方法
5.子类可以隐藏从父类继承的成员变量和方法,如果在子类中想使用被子类隐藏的成员变量或方法就可以使用关键字super。比如super.x、super.play()
就是访问和调用被子类隐藏的成员变量x
和方法play()
调用父类的构造器
l 子类中所有的构造器默认都会访问父类中空参数的构造器
l 当父类中没有空参数的构造器时,子类的构造器必须通过this(参数列表)
或者super(参数列表)
语句指定调用本类或者父类中相应的构造器。同时,只能”二选一”,且必须放在构造器的首行
l 如果子类构造器中既未显式调用父类或本类的构造器,且父类中又没有无参的构造器,则编译出错
注意:
Ø 尤其当子父类出现同名成员时,可以用super
进行区分
Ø super的追溯不仅限于直接父类
Ø super和this的用法相像,this代表本类对象的引用
,super代表父类的内存空间的标识
区别
No. | 区别点 | this | super |
---|---|---|---|
1 | 访问属性 | 访问本类中的属性,如果本类没有此属性则从父类中继续查找 | 直接访问父类中的属性 |
2 | 调用方法 | 访问本类中的方法,如果本类没有此方法则从父类中继续查找 | 直接访问父类中的方法 |
3 | 调用构造器 | 调用本类构造器,必须放在构造器的首行 | 调用父类构造器,必须放在子类构造器的首行 |
4 | 特殊 | 表示当前对象 | 无此概念 |
为什么this或者super要放到第一行
Ø this() 和super()是你如果想用传入当前构造器中的参数或者构造器中的数据调用其他构造器或者控制父类构造器时使用的,在一个构造
器中你只能使用this()或者super()之中的一个,而且调用的位置只能在构造器的第一行,
Ø 在子类中如果你希望调用父类的构造器来初始化父类的部分,那就用合适的参数来调用super(),如果你用没有参数的super()来调用父
类的构造器(同时也没有使用this()来调用其他构造器),父类缺省的构造器会被调用,如果父类没有缺省的构造器,那编译器就会报一
个错误。
22.方法的重载与重写
重载
重载的概念 |
---|
在同一个类中,允许存在一个以上的同名方法,只要它们的参数个数或者参数类型不同即可。 |
重载的特点: |
与返回值类型无关,只看参数列表,且参数列表必须不同。(参数个数或参数类型)。调用时,根据方法参数列表的不同来区别。 |
重载示例: |
//返回两个整数的和 int add(int x,int y){return x+y;} |
//返回三个整数的和 int add(int x,int y,int z){return x+y+z; |
要求:
1.同一个类中(在同一类型中定义的方法)
2.方法名必须相同
3.方法的参数列表不同(①参数的个数不同②参数类型不同)
注意:
1.方法的重载与方法的返回值类型没有关系!
2.方法的返回类型和参数的名字不参与比较
3.方法重载是一种多态的体现。
重写
在子类中可以根据需要对从父类中继承来的方法进行改造,也称方法的重置、覆盖。在程序执行时,子类的方法将覆盖
父类的方法。
要求:
\1. 子类重写的方法必须和父类被重写的方法具有相同的方法名称、参数列表
\2. 子类重写的方法的返回值类型不能大于
父类被重写的方法的返回值类型
\3. 子类重写的方法使用的访问权限不能小于
父类被重写的方法的访问权限
Ø子类不能重写
父类中声明为private权限的方法
\4. 子类方法抛出的异常不能大于
父类被重写方法的异常
1.重写的语法规则
Ø 如果子类继承了父类的实例方法,那么子类就有权利重写
这个方法。
Ø 方法重写是指:子类中定义一个方法,这个方法的类型和父类的方法的类型一致或是父类方法的类型的子类型,且这个方法的名字、参数个数、参数的类型和父类的方法完全相同.
2.重写的目的
Ø 子类通过方法的重写可以隐藏继承的方法
,子类通过方法的重写可以把父类的状态和行为改变为自身的状态和行为。
3.重写后方法的调用
Ø 子类创建的一个对象,如果子类重写了
父类的方法,则运行时系统调用的是子类重写的方法;
Ø 子类创建的一个对象,如果子类未重写
父类的方法,则运行时系统调用的是子类继承的方法;
4.重写的注意事项
重写父类的方法时,不允许降低方法的访问权限,但可以提高访问权限
(访问限制修饰符按访问权限从高到低的排列顺序是:public、protected、友好的(default)、private
。)
子类与父类中同名同参数的方法必须同时声明为非static的(即为重写),或者同时声明为static的(不是重写)。因为static方法是属于类的
,子类无法覆盖父类的方法。
区别
重载:“两同一不同”:同一个类,同一个方法名,不同的参数列表。 注:方法的重载与方法的返回值无关!构造器是可以重载的
重写:(前提:在继承的基础之上,子类在获取了父类的结构以后,可以对父类中同名的方法进行重构)
1.方法的重写Overriding和重载Overloading是Java多态性的不同表现。
2.重写Overriding是父类与子类之间多态性的一种表现,重载Overloading是一个类中多态性的一种表现。
3.如果在子类中定义某方法与其父类有相同的名称和参数,我们说该方法被重写 (Overriding)。
4.子类的对象使用这个方法时,将调用子类中的定义,对它而言,父类中的定义如同被屏蔽了。
5.如果在一个类中定义了多个同名的方法,它们或有不同的参数个数或有不同的参数类型,则称为方法的重载(Overloading)。
23.创建线程的四种方式
l JDK1.5之前创建新执行线程有两种方法:
Ø 继承Thread类的方式
Ø 实现Runnable接口的方式
l JDK5.0新增线程创建方式:
Ø 实现Callable接口
Ø 使用线程池
1.继承Thread类
定义子类继承Thread类。
) 子类中重写Thread类中的run方法。
创建Thread子类对象,即
创建
了线程对象。调用线程对象
start
方法:启动线程,调用run
方法。
l 注意点:
\1. 如果自己手动调用run()
方法,那么就只是普通方法,没有启动多线程模式。
\2. run()
方法由JVM
调用,什么时候调用,执行的过程控制都由操作系统的CPU调度
决定。
\3. 想要启动多线程,必须调用start
方法。
\4. 一个线程对象只能调用一次start()
方法启动,如果重复调用了,则将抛出异常IllegalThreadStateException
。
为什么this或者super要放到第一行
this()
和super()
是你如果想用传入当前构造器中的参数或者构造器中的数据调用其他构造器或者控制父类构造器时使用的,在一个构造器中你只能使用this()
或者super()
之中的一个,而且调用的位置只能在构造器的第一行
,
在子类中如果你希望调用父类的构造器来初始化
父类的部分,那就用合适的参数来调用super()
,如果你用没有参数的super()
来调用父类的构造器(同时也没有使用this()
来调用其他构造器),父类缺省的构造器会被调用
,如果父类没有缺省的构造器,那编译器就会报一个错误
。
2.实现Runnable接口
1)定义子类,实现Runnable
接口。
2)子类中重写Runnable接口中的run方法。
3)通过Thread类含参构造器
创建线程对象。
4)将Runnable接口的子类对象作为实际参数传递给Thread类的构造方法中。
5)调用Thread类的start
方法:开启线程,调用Runnable子类接口的run
方法。
继承方式(Thread)和实现方式(Runnable)的联系与区别
| 区别:
继承Thread: 线程代码存放Thread子类run
方法中。
实现Runnable:线程代码存在接口的子类的run
方法。
| 实现方式(Runnable)的好处:
1)避免了单继承的局限性
2)多个线程可以共享同一个接口实现类的对象,非常适合
多个相同线程来处理同一份资源。
3.实现Callable接口
l 与使用Runnable
相比, Callable
功能更强大些
Ø 相比run()方法,可以有返回值Ø 方法可以抛出异常Ø 支持泛型的返回值Ø 需要借助FutureTask类,比如获取返回结果
l Future接口
Ø 可以对具体Runnable、Callable任务的执行结果进行取消、查询是否完成、获取结果等。Ø FutrueTask是Futrue接口的唯一的实现类Ø FutureTask 同时实现了Runnable, Future接口。它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值
4.使用线程池
线程池相关API
24.String类、StringBuffer类和StringBuilder类
StringBuffer类不同于String,其对象必须使用构造器生成,有三个构造方法(构造器):1.StringBuffer()初始容量为16的字符串缓冲区2.StringBuffer(int size)构造指定容量的字符串缓冲区3.StringBuffer(String str)将内容初始化为指定字符串内容 String s = new String("我喜欢学习"); StringBuffer buffer = new StringBuffer(“我喜欢学习”); buffer.append("数学");
25.Synchronized与Lock的对比
\1. Lock是显式锁
(手动开启和关闭锁,别忘记关闭锁),synchronized是隐式锁
,出了作用域自动释放
\2. Lock只有代码块锁
,synchronized有代码块锁
和方法锁
\3. 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)
优先使用顺序:
Lock ——> 同步代码块(已经进入了方法体,分配了相应资源) ——> 同步方法(在方法体之外)
26.线程的通信
wait() 与 notify() 和 notifyAll()
Ø wait()
:令当前线程挂起并放弃CPU、同步资源并等待,使别的线程可访问并修改共享资源,而当前线程排队等候其他线程调用notify(
)或notifyAll()
方法唤醒,唤醒后等待重新获得对监视器的所有权后才能继续执行。
Ø notify()
:唤醒正在排队等待同步资源的线程中优先级最高者
结束等待
Ø notifyAll ()
:唤醒正在排队等待资源的所有
线程结束等待.
l 这三个方法只有在synchronized
方法或synchronized代码块
中才能使用,否则会报java.lang.IllegalMonitorStateException
异常。
l 因为这三个方法必须有锁对象调用
,而任意对象都可以作为synchronized的同步锁,因此这三个方法只能在Object
类中声明。
wait() 方法
在当前线程中调用方法: 对象名.wait()
使当前线程进入等待(某对象)状态 ,直到另一线程对该对象发出 notify
(或notifyAll
) 为止。
调用方法的必要条件:当前线程必须具有对该对象的监控权(加锁)
调用此方法后,当前线程将释放对象监控权 ,然后进入等待
在当前线程被notify
后,要重新获得监控权,然后从断点处
继续代码的执行。
notify()、notifyAll()
在当前线程中调用方法: 对象名.notify()
功能:唤醒等待该对象监控权的一个线程。
调用方法的必要条件:当前线程必须具有对该对象的监控权(加锁)
27.Java Proxy和CGLIB动态代理原理
动态代理在Java中有着广泛的应用,比如Spring AOP,Hibernate数据查询、测试框架的后端mock、RPC,Java注解对象获取等。静态代理的代理关系在编译时就确定了,而动态代理的代理关系是在编译期确定的。静态代理实现简单,适合于代理类较少且确定的情况,而动态代理则给我们提供了更大的灵活性。今天我们来探讨Java中两种常见的动态代理方式:JDK原生动态代理和CGLIB动态代理。
JDK原生动态代理
先从直观的示例说起,假设我们有一个接口Hello
和一个简单实现HelloImp
:
// 接口
interface Hello{String sayHello(String str);
}
// 实现
class HelloImp implements Hello{@Overridepublic String sayHello(String str) {return "HelloImp: " + str;}
}
这是Java种再常见不过的场景,使用接口制定协议,然后用不同的实现来实现具体行为。假设你已经拿到上述类库,如果我们想通过日志记录对sayHello()
的调用,使用静态代理可以这样做:
// 静态代理方式
class StaticProxiedHello implements Hello{...private Hello hello = new HelloImp();@Overridepublic String sayHello(String str) {logger.info("You said: " + str);return hello.sayHello(str);}
}
上例中静态代理类StaticProxiedHello
作为HelloImp
的代理,实现了相同的Hello
接口。用Java动态代理可以这样做:
- 首先实现一个InvocationHandler,方法调用会被转发到该类的invoke()方法。
- 然后在需要使用Hello的时候,通过JDK动态代理获取Hello的代理对象。
// Java Proxy
// 1. 首先实现一个InvocationHandler,方法调用会被转发到该类的invoke()方法。
class LogInvocationHandler implements InvocationHandler{...private Hello hello;public LogInvocationHandler(Hello hello) {this.hello = hello;}@Overridepublic Object invoke(Object proxy, Method method, Object[] args) throws Throwable {if("sayHello".equals(method.getName())) {logger.info("You said: " + Arrays.toString(args));}return method.invoke(hello, args);}
}
// 2. 然后在需要使用Hello的时候,通过JDK动态代理获取Hello的代理对象。
Hello hello = (Hello)Proxy.newProxyInstance(getClass().getClassLoader(), // 1. 类加载器new Class<?>[] {Hello.class}, // 2. 代理需要实现的接口,可以有多个new LogInvocationHandler(new HelloImp()));// 3. 方法调用的实际处理者
System.out.println(hello.sayHello("I love you!"));
运行上述代码输出结果:
日志信息: You said: [I love you!]
HelloImp: I love you!
上述代码的关键是Proxy.newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler handler)
方法,该方法会根据指定的参数动态创建代理对象。三个参数的意义如下:
loader
,指定代理对象的类加载器;interfaces
,代理对象需要实现的接口,可以同时指定多个接口;handler
,方法调用的实际处理者,代理对象的方法调用都会转发到这里(*注意1)。
newProxyInstance()
会返回一个实现了指定接口的代理对象,对该对象的所有方法调用都会转发给InvocationHandler.invoke()
方法。理解上述代码需要对Java反射机制有一定了解。动态代理神奇的地方就是:
- 代理对象是在程序运行时产生的,而不是编译期;
- 对代理对象的所有接口方法调用都会转发到
InvocationHandler.invoke()
方法,在invoke()
方法里我们可以加入任何逻辑,比如修改方法参数,加入日志功能、安全检查功能等;之后我们通过某种方式执行真正的方法体,示例中通过反射调用了Hello对象的相应方法,还可以通过RPC调用远程方法。
注意1:对于从Object中继承的方法,JDK Proxy会把
hashCode()
、equals()
、toString()
这三个非接口方法转发给InvocationHandler
,其余的Object方法则不会转发。详见JDK Proxy官方文档。
如果对JDK代理后的对象类型进行深挖,可以看到如下信息:
# Hello代理对象的类型信息
class=class jdkproxy.$Proxy0
superClass=class java.lang.reflect.Proxy
interfaces:
interface jdkproxy.Hello
invocationHandler=jdkproxy.LogInvocationHandler@a09ee92
代理对象的类型是jdkproxy.$Proxy0
,这是个动态生成的类型,类名是形如$ProxyN的形式;父类是java.lang.reflect.Proxy
,所有的JDK动态代理都会继承这个类;同时实现了Hello
接口,也就是我们接口列表中指定的那些接口。
如果你还对jdkproxy.$Proxy0
具体实现感兴趣,它大致长这个样子:
// JDK代理类具体实现
public final class $Proxy0 extends Proxy implements Hello
{...public $Proxy0(InvocationHandler invocationhandler){super(invocationhandler);}...@Overridepublic final String sayHello(String str){...return super.h.invoke(this, m3, new Object[] {str});// 将方法调用转发给invocationhandler...}...
}
这些逻辑没什么复杂之处,但是他们是在运行时动态产生的,无需我们手动编写。更多详情,可参考BrightLoong的Java静态代理&动态代理笔记
Java动态代理为我们提供了非常灵活的代理机制,但Java动态代理是基于接口的,如果对象没有实现接口我们该如何代理呢?CGLIB登场。
CGLIB动态代理
CGLIB(Code Generation Library)是一个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB通过继承方式实现代理。
来看示例,假设我们有一个没有实现任何接口的类HelloConcrete
:
public class HelloConcrete {public String sayHello(String str) {return "HelloConcrete: " + str;}
}
因为没有实现接口该类无法使用JDK代理,通过CGLIB代理实现如下:
- 首先实现一个MethodInterceptor,方法调用会被转发到该类的intercept()方法。
- 然后在需要使用HelloConcrete的时候,通过CGLIB动态代理获取代理对象。
// CGLIB动态代理
// 1. 首先实现一个MethodInterceptor,方法调用会被转发到该类的intercept()方法。
class MyMethodInterceptor implements MethodInterceptor{...@Overridepublic Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {logger.info("You said: " + Arrays.toString(args));return proxy.invokeSuper(obj, args);}
}
// 2. 然后在需要使用HelloConcrete的时候,通过CGLIB动态代理获取代理对象。
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(HelloConcrete.class);
enhancer.setCallback(new MyMethodInterceptor());HelloConcrete hello = (HelloConcrete)enhancer.create();
System.out.println(hello.sayHello("I love you!"));
运行上述代码输出结果:
日志信息: You said: [I love you!]
HelloConcrete: I love you!
上述代码中,我们通过CGLIB的Enhancer
来指定要代理的目标对象、实际处理代理逻辑的对象,最终通过调用create()
方法得到代理对象,对这个对象所有非final方法的调用都会转发给MethodInterceptor.intercept()
方法,在intercept()
方法里我们可以加入任何逻辑,比如修改方法参数,加入日志功能、安全检查功能等;通过调用MethodProxy.invokeSuper()
方法,我们将调用转发给原始对象,具体到本例,就是HelloConcrete
的具体方法。CGLIG中MethodInterceptor的作用跟JDK代理中的InvocationHandler
很类似,都是方法调用的中转站。
注意:对于从Object中继承的方法,CGLIB代理也会进行代理,如
hashCode()
、equals()
、toString()
等,但是getClass()
、wait()
等方法不会,因为它是final方法,CGLIB无法代理。
如果对CGLIB代理之后的对象类型进行深挖,可以看到如下信息:
# HelloConcrete代理对象的类型信息
class=class cglib.HelloConcrete$$EnhancerByCGLIB$$e3734e52
superClass=class lh.HelloConcrete
interfaces:
interface net.sf.cglib.proxy.Factory
invocationHandler=not java proxy class
我们看到使用CGLIB代理之后的对象类型是cglib.HelloConcrete$$EnhancerByCGLIB$$e3734e52
,这是CGLIB动态生成的类型;父类是HelloConcrete
,印证了CGLIB是通过继承实现代理;同时实现了net.sf.cglib.proxy.Factory
接口,这个接口是CGLIB自己加入的,包含一些工具方法。
注意,既然是继承就不得不考虑final的问题。我们知道final类型不能有子类,所以CGLIB不能代理final类型,遇到这种情况会抛出类似如下异常:
java.lang.IllegalArgumentException: Cannot subclass final class cglib.HelloConcrete
同样的,final方法是不能重载的,所以也不能通过CGLIB代理,遇到这种情况不会抛异常,而是会跳过final方法只代理其他方法。
如果你还对代理类cglib.HelloConcrete$$EnhancerByCGLIB$$e3734e52
具体实现感兴趣,它大致长这个样子:
// CGLIB代理类具体实现
public class HelloConcrete$$EnhancerByCGLIB$$e3734e52extends HelloConcreteimplements Factory
{...private MethodInterceptor CGLIB$CALLBACK_0; // ~~...public final String sayHello(String paramString){...MethodInterceptor tmp17_14 = CGLIB$CALLBACK_0;if (tmp17_14 != null) {// 将请求转发给MethodInterceptor.intercept()方法。return (String)tmp17_14.intercept(this, CGLIB$sayHello$0$Method, new Object[] { paramString }, CGLIB$sayHello$0$Proxy);}return super.sayHello(paramString);}...
}
上述代码我们看到,当调用代理对象的sayHello()
方法时,首先会尝试转发给MethodInterceptor.intercept()
方法,如果没有MethodInterceptor
就执行父类的sayHello()
。这些逻辑没什么复杂之处,但是他们是在运行时动态产生的,无需我们手动编写。如何获取CGLIB代理类字节码可参考[Access the generated byte] array directly。
更多关于CGLIB的介绍可以参考Rafael Winterhalter的cglib: The missing manual,一篇很深入的文章。
结语
本文介绍了Java两种常见动态代理机制的用法和原理,JDK原生动态代理是Java原生支持的,不需要任何外部依赖,但是它只能基于接口进行代理;CGLIB通过继承的方式进行代理,无论目标对象有没有实现接口都可以代理,但是无法处理final的情况。
动态代理是Spring AOP(Aspect Orient Programming, 面向切面编程)的实现方式,了解动态代理原理,对理解Spring AOP大有帮助。
27.集合
Java 集合可分为 Collection 和 Map 两种体系
ØCollection接口:单列数据
,定义了存取一组对象的方法的集合
- List:元素有序、可重复的集合
- Set:元素无序、不可重复的集合
Ø Map接口:双列数据
,保存具有映射关系key-value对
的集合
Collection接口继承树
Map接口继承树
29.类的加载过程
30.什么时候会发生类初始化
l 类的主动引用(一定会发生类的初始化)
Ø 当虚拟机启动,先初始化main方法所在的类Ø new一个类的对象Ø 调用类的静态成员(除了final常量)和静态方法Ø 使用java.lang.reflect包的方法对类进行反射调用Ø 当初始化一个类,如果其父类没有被初始化,则先会初始化它的父类
l 类的被动引用(不会发生类的初始化)
Ø 当访问一个静态域时,只有真正声明这个域的类才会被初始化当通过子类引用父类的静态变量,不会导致子类初始化Ø 通过数组定义类引用,不会触发此类的初始化Ø 引用常量不会触发此类的初始化(常量在链接阶段就存入调用类的常量池中了)
31.类加载器(ClassLoader)
类加载器是用来把类(class)装载进内存的。JVM 规范定义了两种类型的类加载器:启动类加载器(bootstrap)
和用户自定义加载器(user-defined class loader)
。
JVM在运行时会产生3个类加载器
组成的初始化加载器层次结构 ,如下图所示:
l 类加载的作用:将class
文件字节码内容加载到内存中,并将这些静态数据转换成方法区
的运行时数据结构,然后在堆
中生成一个代表这个类的java.lang.Class
对象,作为方法区中类数据的访问入口。
l 类缓存:标准的JavaSE类加载器可以按要求查找类,但一旦某个类被加载到类加载器中,它将维持加载(缓存)一段时间。不过JVM
垃圾回收机制可以回收这些Class
对象。
32.反射可以取得类的哪些东西
33.实例化Class类对象(四种方法)
34.哪些类型可以有Class对象
35.为什么要重写equals和hashcode方法
一、前言
我们都知道,要比较两个对象是否相等时需要调用对象的equals()方法,即判断对象引用所指向的对象地址是否相等,对象地址相等时,那么与对象相关的对象句柄、对象头、对象实例数据、对象类型数据等也是完全一致的,所以我们可以通过比较对象的地址来判断是否相等。
二、Object源码理解
对象在不重写的情况下使用的是Object的equals方法和hashcode方法,从Object类的源码我们知道,默认的equals 判断的是两个对象的引用指向的是不是同一个对象;而hashcode也是根据对象地址生成一个整数数值;
另外我们可以看到Object的hashcode()方法的修饰符为native,表明该方法是由操作系统实现,java调用操作系统底层代码获取哈希值。
public class Object {public native int hashCode();public boolean equals(Object obj) {return (this == obj);}
}
三、需要重写equals()的场景
假设现在有很多学生对象,默认情况下,要判断多个学生对象是否相等,需要根据地址判断,若对象地址相等,那么对象的实例数据一定是一样的,但现在我们规定:当学生的姓名、年龄、性别相等时,认为学生对象是相等的,不一定需要对象地址完全相同,例如学生A对象所在地址为100,学生A的个人信息为(姓名:A,性别:女,年龄:18,住址:北京软件路999号,体重:48),学生A对象所在地址为388,学生A的个人信息为(姓名:A,性别:女,年龄:18,住址:广州暴富路888号,体重:55),这时候如果不重写Object的equals方法,那么返回的一定是false不相等,这个时候就需要我们根据自己的需求重写equals()方法了。
public class Student {private String name;// 姓名private String sex;// 性别private String age;// 年龄private float weight;// 体重private String addr;// 地址// 重写hashcode方法@Overridepublic int hashCode() {int result = name.hashCode();result = 17 * result + sex.hashCode();result = 17 * result + age.hashCode();return result;}// 重写equals方法@Overridepublic boolean equals(Object obj) {if(!(obj instanceof Student)) {// instanceof 已经处理了obj = null的情况return false;}Student stuObj = (Student) obj;// 地址相等if (this == stuObj) {return true;}// 如果两个对象姓名、年龄、性别相等,我们认为两个对象相等if (stuObj.name.equals(this.name) && stuObj.sex.equals(this.sex) && stuObj.age.equals(this.age)) {return true;} else {return false;}}public String getName() {return name;}public void setName(String name) {this.name = name;}public String getSex() {return sex;}public void setSex(String sex) {this.sex = sex;}public String getAge() {return age;}public void setAge(String age) {this.age = age;}public float getWeight() {return weight;}public void setWeight(float weight) {this.weight = weight;}public String getAddr() {return addr;}public void setAddr(String addr) {this.addr = addr;}}
现在我们写个例子测试下结果:
@Test
public void test8(){Student1 s1 =new Student1();s1.setAddr("1111");s1.setAge("20");s1.setName("allan");s1.setSex("male");s1.setWeight(60f);Student1 s2 =new Student1();s2.setAddr("222");s2.setAge("20");s2.setName("allan");s2.setSex("male");s2.setWeight(70f);if(s1.equals(s2)) {System.out.println("s1==s2");}else {System.out.println("s1 != s2");}
}
在重写了student的equals方法后,这里会输出s1 == s2
,实现了我们的需求,如果没有重写equals方法,那么上段代码必定输出s1!=s2
。
通过上面的例子,你是不是会想,不是说要同时重写Object的equals方法和hashcode方法吗?那上面的例子怎么才只用到equals方法呢,hashcode方法没有体现出来,不要着急,我们往下看。
四、需要重写hashcode()的场景
以上面例子为基础,即student1和student2在重写equals方法后被认为是相等的。在两个对象equals的情况下进行把他们分别放入Map和Set中。在上面的代码基础上追加如下代码:
Set set = new HashSet();
set.add(s1);
set.add(s2);
System.out.println(set);
注释掉student类中hashcode方法块,这里会输出
[Student1@4b9af9a9, Student1@5387f9e0]
说明该Set容器类有2个元素。…等等,为什么会有2个元素????刚才经过测试,s1不是已经等于s2了吗,那按照Set容器的特性会有一个去重操作,那为什么现在会有2个元素。这就涉及到Set的底层实现问题了,这里简单介绍下就是HashSet的底层是通过HashMap实现的,最终比较set容器内元素是否相等是通过比较对象的hashcode来判断的。现在你可以试试吧刚才注释掉的hashcode方法弄回去,然后重新运行,看是不是很神奇的就只输出一个元素了,重写hashcode方法后输出的结果为:
[Student1@43c2ce69]
或许你会有一个疑问?hashcode里的代码该怎么理解?该如何写?其实有个相对固定的写法,先整理出你判断对象相等的属性,然后取一个尽可能小的正整数(尽可能小时怕最终得到的结果超出了整型int的取数范围),这里我取了17,(好像在JDK源码中哪里看过用的是17),然后计算17*属性的hashcode+其他属性的hashcode,重复步骤。
自定义类如何重写hashCode方法
要重写自己的hashCode方法并没有什么绝对正确的答案,但是我们的目标是:不相等的对象尽可能有不同的hashCode,而且必须满足的一个通用约定是:相等的对象应该具有相同的hashCode
。下面介绍一种hashCode的实现方式,这种实现方式对一般的程序来说足够了,至于如何实现更完美的hashCode方法就留给数学家或者理论家去讨论吧。
第一步:定义一个初始值,一般来说取17
int result = 17;
第二步:分别解析自定义类中与equals方法相关的字段(假如hashCode中考虑的字段在equals方法中没有考虑,则两个equals的对象就很可能具有不同的hashCode)
情况一:字段a类型为boolean 则[hashCode] = a ? 1 : 0;情况二:字段b类型为byte/short/int/char, 则[hashCode] = (int)b;情况三:字段c类型为long, 则[hashCode] = (int) (c ^ c>>>32);情况四:字段d类型为float, 则[hashCode] = d.hashCode()(内部调用的是Float.hashCode(d), 而该静态方法内部调用的另一个静态方法是Float.floatToIntBits(d))情况五:字段e类型为double, 则[hashCode] = e.hashCode()(内部调用的是Double.hashCode(e), 而该静态方法内部调用的另一个静态方法是Double.doubleToLongBits(e),得到一个long类型的值之后,跟情况三进行类似的操作,得到一个int类型的值)情况六:引用类型,若为null则hashCode为0,否则递归调用该引用类型的hashCode方法。情况七:数组类型。(要获取数组类型的hashCode,可采用如下方法:s[0]*31 ^ (n-1) + s[1] * 31 ^ (n-2) + ..... + s[n-1], 该方法正是String类的hashCode实现所采用的算法)第三步:对于涉及到的各个字段,采用第二步中的方式,将其依次应用于下式:result = result * 31 + [hashCode];补充说明一点:如果初始值result不取17而取0的话,则对于hashCode为0的字段来说就没有区分度了,这样更容易产生冲突。比如两个自定义类中,一个类比另一个类多出来一个或者几个字段,其余字段全部一样,分别new出来2个对象,这2个对象共有的字段的值全是一样的,而对于多来的那些字段的值正好都是0,并且在计算hashCode时这些多出来的字段又是最先计算的,这样的话,则这两个对象的hashCode就会产生冲突。还是那句话,hashCode方法的实现没有最好,只有更好。
————————————————
版权声明:本文为CSDN博主「奔跑吧小蜗牛」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/abinge317/article/details/51437179
同理,可以测试下放入HashMap中,key为<s1,s1>,<s2,s2>,Map也把两个同样的对象当成了不同的Key(Map的Key是不允许重复的,相同Key会覆盖)那么没有重写的情况下map中也会有2个元素,重写的情况会最后put进的元素会覆盖前面的value
Map m = new HashMap();
m.put(s1, s1);
m.put(s2, s2);
System.out.println(m);
Collection values = m.values();
Iterator iterator = values.iterator();
while (iterator.hasNext()){Student1 next = (Student1)iterator.next();System.out.println(next.getAddr());
}重写hashcode输出结果:
222不重写hashcode输出结果:
1111
222
五、原理分析
因为我们没有重写父类(Object)的hashcode方法,Object的hashcode方法会根据两个对象的地址生成对相应的hashcode;
s1和s2是分别new出来的,那么他们的地址肯定是不一样的,自然hashcode值也会不一样。
Set区别对象是不是唯一的标准是,两个对象hashcode是不是一样,再判定两个对象是否equals;
Map 是先根据Key值的hashcode分配和获取对象保存数组下标的,然后再根据equals区分唯一值(详见下面的map分析)
2022Java面试笔记(上)相关推荐
- labuladong的算法小抄pdf_真漂亮!这份GitHub上爆火的算法面试笔记,助你圆满大厂梦...
前言 Github作为程序员们的后花园,一直以来都是程序员最喜欢逛逛.学习的地方,小编也不例外,最近看到一份对标BAT等一线大厂的算法面试笔记,已经标星68+K了,很是惊讶,看了一下,觉得知识点整理得 ...
- 怎么判断自己启动的线程是否执行完成 java_Java面试笔记(上)
面试整体流程(HR 或技术面) 1.请简单的自我介绍 我叫***,工作*年了,先后做过**项目.**项目. 2.请你简单的介绍一下**项目 该系统主要有哪些部分组成,简单介绍项目的整体架构,具体参与某 ...
- GitHub上AI岗位面试笔记(机器学习算法/深度学习/ NLP/计算机视觉)
目录 机器学习 深度学习 自然语言处理与数学 算法题和笔试题 推荐阅读 工具 最近在GitHub上淘到一个很棒的AI算法面试笔记,特地分享给小伙伴们~ GitHub地址:https://github. ...
- 【JVM性能优化,这套Github上40K+star面试笔记
/proc/${PID}/fd/proc/${PID}/task 可以分别查看句柄详情和线程数. 例如,某一台线上服务器的sshd进程PID是9339,查看 ll /proc/9339/fdll /p ...
- 应有尽有!这可能是最全的 AI 面试笔记了
点击上方"视学算法",选择"星标"公众号 重磅干货,第一时间送达 今天给大家推荐一个非常全面的 AI 面试笔记集锦,包含 2018.2019 年的校招.春招.秋 ...
- 炼丹面试官的面试笔记
作者:无名,某小公司算法专家 排版:一元,四品炼丹师 公众号:炼丹笔记 关于Attention和Transformer的灵魂拷问 背景 现在几乎很多搞深度学习的朋友都把attention和Transf ...
- Java高级开发工程师面试笔记
最近在复习面试相关的知识点,然后做笔记,后期(大概在2018.02.01)会分享给大家,尽自己最大的努力做到最好,还希望到时候大家能给予建议和补充 ----------------2018.03.05 ...
- react取消捕获_React 面试指南 (上)
使用 React 进行项目开发也有好几个项目了,趁着最近有空来对 React 的知识做一个简单的复盘. 完整目录概览 React 是单向数据流还是双向数据流?它还有其他特点吗? setState Re ...
- jsjq面试笔记(下)
js&jq面试笔记,该部分为下部分. 字符串相关 1.定义一个方法,用于将string中的每个字符之间加一个空格,并输出 如:'hello' -> 'h e l l o'function ...
最新文章
- android将矩阵转换成字节数组,android-使用OpenGL矩阵转换将纹理从“ 1D”映...
- 图的遍历[摘录自严长生老师的网站]
- mobileNet v2网络详解
- 生物智能与AI——关乎创造、关乎理解(下)
- 【PHP】月末・月初の出力方法
- GridFsTemplate介绍以及基本使用
- jdbc 3种获得mysql插入数据的自增字段值的方法_JDBC 3种获得mysql插入数据的自增字段值的方法...
- 图像处理十:图像反色
- java发送带附件的邮件_Java发送邮件(带附件)
- 基于华为产品的高校云数据中心建设规划设计方案
- 键盘上的prtsc,scrlk,pause键作用
- UCOS操作系统——中断和时间管理(七)
- 高效能管理之要事第一 时间管理表格2
- 1.1、信息化和信息系统
- 【ant.design】解决Instance created by `useForm` is not connected to any Form element.
- ubuntu系统更新后分辨率变低的问题之一
- 【每日新闻】诺基亚展示未来工厂:5G自动化机器人与人类和谐共处
- java基础之后台线程_繁星漫天_新浪博客
- 2-5Java基本语法----程序流程控制(1)if-else结构练习2----三个数排序
- 计算机高考英语,高考英语优秀作文 Computer(计算机)