这篇文章是想记录自己看到的面试题,然后做个总结.不仅仅帮助到我,也希望可以帮助到大家.有疑问可以联系我.
基本类型 | 大小(字节) | 默认值 | 封装类 |
---|---|---|---|
byte | 1 | (byte)0 | Byte |
short | 2 | (short)2 | Short |
int | 4 | 0 | Integer |
long | 8 | 0L | Long |
float | 4 | 0.0f | Float |
double | 8 | 0.0d | Double |
boolean | - | false | Boolean |
char | 2 | \u0000(null) | Character |
void | - | - | Void |
引用数据类型是由类的编辑器定义的,他们是用于访问对象的。这些变量被定义为不可更改的特定类型。
jdk7之前
switch 只能支持 byte、short、char、int 这几个基本数据类型和其对应的封装类型。
switch后面的括号里面只能放int类型的值,但由于byte,short,char类型,它们会?自动?转换为int类型(精精度小的向大的转化),所以它们也支持。
jdk1.7后
整形,枚举类型,字符串都可以。
public class SwitchStringText {
static String string = "123";
public static void main(String[] args) {
switch (string) {
case "123":
System.out.println("123");
break;
case "abc":
System.out.println("abc");
break;
default:
System.out.println("default");
break;
}
}
}
使用==比较原生类型如:boolean、int、char等等,使用equals()比较对象。
1、==是判断两个变量或实例是不是指向同一个内存空间。 equals是判断两个变量或实例所指向的内存空间的值是不是相同。
2、==是指对内存地址进行比较。 equals()是对字符串的内容进行比较。
3、==指引用是否相同。 equals()指的是值是否相同。
public static void main(String[] args) {
String a = new String("ab"); // a 为一个引用
String b = new String("ab"); // b为另一个引用,对象的内容一样
String aa = "ab"; // 放在常量池中
String bb = "ab"; // 从常量池中查找
System.out.println(aa == bb); // true
System.out.println(a == b); // false,非同一对象
System.out.println(a.equals(b)); // true
System.out.println(42 == 42.0); // true
}
public static void main(String[] args) {
Object obj1 = new Object();
Object obj2 = new Object();
System.out.println(obj1.equals(obj2));//false
System.out.println(obj1==obj2);//false
obj1=obj2;
System.out.println(obj1==obj2);//true
System.out.println(obj2==obj1);//true
}
自动装箱 在jdk1.5之前,如果你想要定义一个value为100的Integer对象,则需要如下定义:
Integer i = new Integer(100);
int intNum1 = 100; //普通变量
Integer intNum2 = intNum1; //自动装箱
int intNum3 = intNum2; //自动拆箱
Integer intNum4 = 100; //自动装箱
上面的代码中,intNum2为一个Integer类型的实例,intNum1为Java中的基础数据类型,将intNum1赋值给intNum2便是自动装箱;而将intNum2赋值给intNum3则是自动拆箱。
八种基本数据类型: boolean byte char shrot int long float double ,所生成的变量相当于常量。
基本类型包装类:Boolean Byte Character Short Integer Long Float Double。
自动拆箱和自动装箱定义:
自动装箱是将一个java定义的基本数据类型赋值给相应封装类的变量。 拆箱与装箱是相反的操作,自动拆箱则是将一个封装类的变量赋值给相应基本数据类型的变量。
Object是所有类的父类,任何类都默认继承Object类
clone
保护方法,实现对象的浅复制,只有实现了Cloneable接口才可以调用该方法,否则抛出CloneNotSupportedException异常。
equals
在Object中与==是一样的,子类一般需要重写该方法。
hashCode
该方法用于哈希查找,重写了equals方法一般都要重写hashCode方法,这个方法在一些具有哈希功能的Collection中用到。
getClass
final方法,获得运行时类型
wait
使当前线程等待该对象的锁,当前线程必须是该对象的拥有者,也就是具有该对象的锁。 wait() 方法一直等待,直到获得锁或者被中断。 wait(long timeout) 设定一个超时间隔,如果在规定时间内没有获得锁就返回。
调用该方法后当前线程进入睡眠状态,直到以下事件发生
1、其他线程调用了该对象的notify方法。 2、其他线程调用了该对象的notifyAll方法。 3、其他线程调用了interrupt中断该线程。 4、时间间隔到了。此时该线程就可以被调度了,如果是被中断的话就抛出一个InterruptedException异常。
notify
唤醒在该对象上等待的某个线程。
notifyAll
唤醒在该对象上等待的所有线程。
toString
转换成字符串,一般子类都有重写,否则打印句柄。
浅拷贝
被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。
换言之,浅拷贝仅仅复制所考虑的对象,而不复制它所引用的对象。
深拷贝
被复制对象的所有变量都含有与原来的对象相同的值,除去那些引用其他对象的变量。
那些引用其他对象的变量将指向被复制过的新对象,而不再是原有的那些被引用的对象。
换言之,深拷贝把要复制的对象所引用的对象都要复制一遍。
序列化
内部创建新对象构造就可以了
采用cloneabel接口进行深拷贝
实现Cloneable接口(但是要将所有对象中的引用对象都实现Cloneable接口,并且在对象的clone方法中调用,就是层层克隆的作用,保证所有数据都被克隆)
Java中对象的克隆:
为了获取对象的一份拷贝,可以利用Object类的clone()方法。
在派生类中覆盖基类的clone()方法,并声明为public。(Object类中的clone()方法是Protected的)。在子类重写的时候,可以扩大访问修饰符的范围。
在派生类的clone()方法中,调用super.clone()。因为在运行时刻,Object类中的clone()识别出你要复制的是哪一个对象,然后为此对象分配空间,并进行对象的复制,将原始对象的内容一一复制到新对象的存储空间中。
在派生类中实现Cloneable接口。
从JDK1.2版本开始,把对象的引用分为四种级别,从而使程序能更加灵活的控制对象的生命周期。这四种级别由高到低依次为:强引用、软引用、弱引用和虚引用。
1.强引用
最普遍的一种引用方式,如String s = “abc”,变量s就是字符串“abc”的强引用,只要强引用存在,则垃圾回收器就不会回收这个对象。
2.软引用(SoftReference)
用于描述还有用但非必须的对象,如果内存足够,不回收,如果内存不足,则回收。一般用于实现内存敏感的高速缓存,软引用可以和引用队列ReferenceQueue联合使用,如果软引用的对象被垃圾回收,JVM就会把这个软引用加入到与之关联的引用队列中。
3.弱引用(WeakReference)
弱引用和软引用大致相同,弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
4.虚引用(PhantomReference)
就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。 虚引用主要用来跟踪对象被垃圾回收器回收的活动。
虚引用与软引用和弱引用的一个区别在于:
虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
1、HashCode的特性
(1)HashCode的存在主要是用于查找的快捷性,如Hashtable,HashMap等,HashCode经常用于确定对象的存储地址。
(2)如果两个对象相同,equals方法一定返回true,并且这两个对象的HashCode一定相同。
(3)两个对象的HashCode相同,并不一定表示两个对象就相同,即equals()不一定为true,只能够说明这两个对象在一个散列存储结构中。
(4)如果对象的equals方法被重写,那么对象的HashCode也尽量重写。
2、HashCode作用
Java中的集合有两类,一类是List,再有一类是Set。前者集合内的元素是有序的,元素可以重复;后者元素无序,但元素不可重复。
equals方法可用于保证元素不重复,但如果每增加一个元素就检查一次,若集合中现在已经有1000个元素,那么第1001个元素加入集合时,就要调用1000次equals方法。这显然会大大降低效率。于是,Java采用了哈希表的原理。
哈希算法也称为散列算法,是将数据依特定算法直接指定到一个地址上。
这样一来,当集合要添加新的元素时,先调用这个元素的HashCode方法,就一下子能定位到它应该放置的物理位置上。
(1)如果这个位置上没有元素,它就可以直接存储在这个位置上,不用再进行任何比较了。
(2)如果这个位置上已经有元素了,就调用它的equals方法与新元素进行比较,相同的话就不存了。
(3)不相同的话,也就是发生了Hash key相同导致冲突的情况,那么就在这个Hash key的地方产生一个链表,将所有产生相同HashCode的对象放到这个单链表上去,串在一起(很少出现)。
这样一来实际调用equals方法的次数就大大降低了,几乎只需要一两次。
从Object角度看,JVM每new一个Object,它都会将这个Object丢到一个Hash表中去,这样的话,下次做Object的比较或者取这个对象的时候(读取过程),它会根据对象的HashCode再从Hash表中取这个对象。这样做的目的是提高取对象的效率。若HashCode相同再去调用equals。
3、HashCode实践(如何用来查找)
HashCode是用于查找使用的,而equals是用于比较两个对象是否相等的。
(1)例如内存中有这样的位置
0 1 2 3 4 5 6 7
而我有个类,这个类有个字段叫ID,我要把这个类存放在以上8个位置之一,如果不用HashCode而任意存放,那么当查找时就需要到这八个位置里挨个去找,或者用二分法一类的算法。
但以上问题如果用HashCode就会使效率提高很多 定义我们的HashCode为ID%8,比如我们的ID为9,9除8的余数为1,那么我们就把该类存在1这个位置,如果ID是13,求得的余数是5,那么我们就把该类放在5这个位置。依此类推。
(2)但是如果两个类有相同的HashCode,例如9除以8和17除以8的余数都是1,也就是说,我们先通过HashCode来判断两个类是否存放某个桶里,但这个桶里可能有很多类,那么我们就需要再通过equals在这个桶里找到我们要的类。
请看下面这个例子
public class HashTest {
private int i;
public int getI() {
return i;
}
public void setI(int i) {
this.i = i;
}
public int hashCode() {
return i % 10;
}
public final static void main(String[] args) {
HashTest a = new HashTest();
HashTest b = new HashTest();
a.setI(1);
b.setI(1);
Set<HashTest> set = new HashSet<HashTest>();
set.add(a);
set.add(b);
System.out.println(a.hashCode() == b.hashCode());
System.out.println(a.equals(b));
System.out.println(set);
}
}
输出结果为:
true
False
[HashTest@1, HashTest@1]
以上这个示例,我们只是重写了HashCode方法,从上面的结果可以看出,虽然两个对象的HashCode相等,但是实际上两个对象并不是相等,因为我们没有重写equals方法,那么就会调用Object默认的equals方法,显示这是两个不同的对象。
这里我们将生成的对象放到了HashSet中,而HashSet中只能够存放唯一的对象,也就是相同的(适用于equals方法)的对象只会存放一个,但是这里实际上是两个对象ab都被放到了HashSet中,这样HashSet就失去了他本身的意义了。
下面我们继续重写equals方法:
public class HashTest {
private int i;
public int getI() {
return i;
}
public void setI(int i) {
this.i = i;
}
public boolean equals(Object object) {
if (object == null) {
return false;
}
if (object == this) {
return true;
}
if (!(object instanceof HashTest)) {
return false;
}
HashTest other = (HashTest) object;
if (other.getI() == this.getI()) {
return true;
}
return false;
}
public int hashCode() {
return i % 10;
}
public final static void main(String[] args) {
HashTest a = new HashTest();
HashTest b = new HashTest();
a.setI(1);
b.setI(1);
Set<HashTest> set = new HashSet<HashTest>();
set.add(a);
set.add(b);
System.out.println(a.hashCode() == b.hashCode());
System.out.println(a.equals(b));
System.out.println(set);
}
}
输出结果如下所示。
从结果我们可以看出,现在两个对象就完全相等了,HashSet中也只存放了一份对象。
注意:
hashCode()只是简单示例写的,真正的生产换将不是这样的
true
true
[HashTest@1]
hashCode的存在主要是用于查找的快捷性,如Hashtable,HashMap等,hashCode是用来在散列存储结构中确定对象的存储地址的。
如果两个对象相同,就是适用于equals(java.lang.Object) 方法,那么这两个对象的hashCode一定要相同。
如果对象的equals方法被重写,那么对象的hashCode也尽量重写,并且产生hashCode使用的对象,一定要和equals方法中使用的一致,否则就会违反上面提到的第2点。
两个对象的hashCode相同,并不一定表示两个对象就相同,也就是不一定适用于equals(java.lang.Object) 方法,只能够说明这两个对象在散列存储结构中,如Hashtable,他们“存放在同一个篮子里”。
什么时候需要重写?
一般的地方不需要重载hashCode,只有当类需要放在HashTable、HashMap、HashSet等等hash结构的集合时才会重载hashCode,那么为什么要重载hashCode呢?
要比较两个类的内容属性值,是否相同时候,根据hashCode 重写规则,重写类的 指定字段的hashCode(),equals()方法。
例如:
public class EmpWorkCondition{
/**
* 员工ID
*/
private Integer empId;
/**
* 员工服务总单数
*/
private Integer orderSum;
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
EmpWorkCondition that = (EmpWorkCondition) o;
return Objects.equals(empId, that.empId);
}
@Override
public int hashCode() {
return Objects.hash(empId);
}
// 省略 getter setter
}
public static void main(String[] args) {
List<EmpWorkCondition> list1 = new ArrayList<EmpWorkCondition>();
EmpWorkCondition emp1 = new EmpWorkCondition();
emp1.setEmpId(100);
emp1.setOrderSum(90000);
list1.add(emp1);
List<EmpWorkCondition> list2 = new ArrayList<EmpWorkCondition>();
EmpWorkCondition emp2 = new EmpWorkCondition();
emp2.setEmpId(100);
list2.add(emp2);
System.out.println(list1.contains(emp2));
}
输出结果为:
true
上面的方法,做的事情就是,比较两个集合中的,实体类对象属性值,是否一致
OrderSum 不在比较范围内,因为没有重写它的,equals()和hashCode()方法
为什么要重载equals方法?
因为Object的equals方法默认是两个对象的引用的比较,意思就是指向同一内存,地址则相等,否则不相等;如果你现在需要利用对象里面的值来判断是否相等,则重载equals方法。
一般的地方不需要重载hashCode,只有当类需要放在HashTable、HashMap、HashSet等等hash结构的集合时才会重载hashCode,那么为什么要重载hashCode呢?
如果你重写了equals,比如说是基于对象的内容实现的,而保留hashCode的实现不变,那么很可能某两个对象明明是“相等”,而hashCode却不一样。
这样,当你用其中的一个作为键保存到hashMap、hasoTable或hashSet中,再以“相等的”找另一个作为键值去查找他们的时候,则根本找不到。
为什么equals()相等,hashCode就一定要相等,而hashCode相等,却不要求equals相等?
1、因为是按照hashCode来访问小内存块,所以hashCode必须相等。 2、HashMap获取一个对象是比较key的hashCode相等和equals为true。
之所以hashCode相等,却可以equals不等,就比如ObjectA和ObjectB他们都有属性name,那么hashCode都以name计算,所以hashCode一样,但是两个对象属于不同类型,所以equals为false。
为什么需要hashCode?
1、通过hashCode可以很快的查到小内存块。 2、通过hashCode比较比equals方法快,当get时先比较hashCode,如果hashCode不同,直接返回false。
List的三个子类的特点
ArrayList:
Vector:
LinkedList
Vector和ArrayList的区别
ArrayList和LinkedList的区别
List有三个子类使用
String
:适用于少量的字符串操作的情况。
StringBuilder
适用于单线程下在字符缓冲区进行大量操作的情况。
StringBuffer
适用多线程下在字符缓冲区进行大量操作的情况。
StringBuilder是线程不安全的,而StringBuffer是线程安全的。
这三个类之间的区别主要是在两个方面,即运行速度和线程安全这两方面。 首先说运行速度,或者说是执行速度,在这方面运行速度快慢为:StringBuilder > StringBuffer > String。
String最慢的原因
String为字符串常量,而StringBuilder和StringBuffer均为字符串变量,即String对象一旦创建之后该对象是不可更改的,但后两者的对象是变量,是可以更改的。
再来说线程安全
在线程安全上,StringBuilder是线程不安全的,而StringBuffer是线程安全的。
如果一个StringBuffer对象在字符串缓冲区被多个线程使用时,StringBuffer中很多方法可以带有synchronized关键字,所以可以保证线程是安全的,但StringBuilder的方法则没有该关键字,所以不能保证线程安全,有可能会出现一些错误的操作。所以如果要进行的操作是多线程的,那么就要使用StringBuffer,但是在单线程的情况下,还是建议使用速度比较快的StringBuilder。
Map
Set
List
Queue
Stack
用法
更为精炼的总结
Collection 是对象集合, Collection 有两个子接口 List 和 Set
List 可以通过下标 (1,2…) 来取得值,值可以重复。 Set 只能通过游标来取值,并且值是不能重复的。
ArrayList , Vector , LinkedList 是 List 的实现类
Map 是键值对集合
Stack类:继承自Vector,实现一个后进先出的栈。提供了几个基本方法,push、pop、peak、empty、search等。
Queue接口:提供了几个基本方法,offer、poll、peek等。已知实现类有LinkedList、PriorityQueue等。
Hashtable是基于陈旧的Dictionary类的,HashMap是Java 1.2引进的Map接口的一个实现,它们都是集合中将数据无序存放的。
1、hashMap去掉了HashTable的contains方法,但是加上了containsValue()和containsKey()方法
HashTable Synchronize同步的,线程安全,HashTable不允许空键值为空,效率低。
HashMap 非Synchronize线程同步的,线程不安全,HashMap允许空键值为空,效率高。
Hashtable是基于陈旧的Dictionary类的,HashMap是Java 1.2引进的Map接口的一个实现,它们都是集合中将数据无序存放的。
Hashtable的方法是同步的,HashMap未经同步,所以在多线程场合要手动同步HashMap这个区别就像Vector和ArrayList一样。
查看Hashtable的源代码就可以发现,除构造函数外,Hashtable的所有 public 方法声明中都有 synchronized 关键字,而HashMap的源代码中则连 synchronized 的影子都没有,当然,注释除外。
2、Hashtable不允许 null 值(key 和 value 都不可以),HashMap允许 null 值(key和value都可以)。
3、两者的遍历方式大同小异,Hashtable仅仅比HashMap多一个elements方法。
Hashtable table = new Hashtable();
table.put("key", "value");
Enumeration em = table.elements();
while (em.hasMoreElements()) {
String obj = (String) em.nextElement();
System.out.println(obj);
}
4、HashTable使用Enumeration,HashMap使用Iterator
从内部机制实现上的区别如下:
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
而HashMap重新计算hash值,而且用与代替求模:
int hash = hash(k);
int i = indexFor(hash, table.length);
static int hash(Object x) {
int h = x.hashCode();
h += ~(h << 9);
h ^= (h >>> 14);
h += (h << 4);
h ^= (h >>> 10);
return h;
}
static int indexFor(int h, int length) {
return h & (length-1);
}
JDK7中的HashMap
HashMap底层维护一个数组,数组中的每一项都是一个Entry。
transient Entry<K,V>[] table;
我们向 HashMap 中所放置的对象实际上是存储在该数组当中。 而Map中的key,value则以Entry的形式存放在数组中。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
总结一下map.put后的过程:
当向 HashMap 中 put 一对键值时,它会根据 key的 hashCode 值计算出一个位置, 该位置就是此对象准备往数组中存放的位置。
如果该位置没有对象存在,就将此对象直接放进数组当中;如果该位置已经有对象存在了,则顺着此存在的对象的链开始寻找(为了判断是否是否值相同,map不允许
JDK8中的HashMap
JDK8中采用的是位桶+链表/红黑树(有关红黑树请查看红黑树)的方式,也是非线程安全的。当某个位桶的链表的长度达到某个阀值的时候,这个链表就将转换成红黑树。
JDK8中,当同一个hash值的节点数不小于8时,将不再以单链表的形式存储了,会被调整成一颗红黑树(上图中null节点没画)。这就是JDK7与JDK8中HashMap实现的最大区别。
接下来,我们来看下JDK8中HashMap的源码实现。
JDK中Entry的名字变成了Node,原因是和红黑树的实现TreeNode相关联。
transient Node<K,V>[] table;
当冲突节点数不小于8-1时,转换成红黑树。
]static final int TREEIFY_THRESHOLD = 8;
为了线程安全从ConcurrentHashMap代码中可以看出,它引入了一个“分段锁”的概念,具体可以理解为把一个大的Map拆分成N个小的HashTable,根据key.hashCode()来决定把key放到哪个HashTable中。
HashMap本质是数组加链表。根据key取得hash值,然后计算出数组下标,如果多个key对应到同一个下标,就用链表串起来,新插入的在前面。
ConcurrentHashMap:在hashMap的基础上,ConcurrentHashMap将数据分为多个segment,默认16个(concurrency level),然后每次操作对一个segment加锁,避免多线程锁的几率,提高并发效率。
总结
JDK6,7中的ConcurrentHashMap主要使用Segment来实现减小锁粒度,把HashMap分割成若干个Segment,在put的时候需要锁住Segment,get时候不加锁,使用volatile来保证可见性,当要统计全局时(比如size),首先会尝试多次计算modcount来确定,这几次尝试中,是否有其他线程进行了修改操作,如果没有,则直接返回size。如果有,则需要依次锁住所有的Segment来计算。
jdk7中ConcurrentHashMap中,当长度过长碰撞会很频繁,链表的增改删查操作都会消耗很长的时间,影响性能。
jdk8 中完全重写了concurrentHashMap,代码量从原来的1000多行变成了 6000多 行,实现上也和原来的分段式存储有很大的区别。
JDK8中采用的是位桶+链表/红黑树(有关红黑树请查看红黑树)的方式,也是非线程安全的。当某个位桶的链表的长度达到某个阀值的时候,这个链表就将转换成红黑树。
JDK8中,当同一个hash值的节点数不小于8时,将不再以单链表的形式存储了,会被调整成一颗红黑树。这就是JDK7与JDK8中HashMap实现的最大区别。
主要设计上的变化有以下几点
1.jdk8不采用segment而采用node,锁住node来实现减小锁粒度。 2.设计了MOVED状态 当resize的中过程中 线程2还在put数据,线程2会帮助resize。 3.使用3个CAS操作来确保node的一些操作的原子性,这种方式代替了锁。 4.sizeCtl的不同值来代表不同含义,起到了控制的作用。
至于为什么JDK8中使用synchronized而不是ReentrantLock,是因为JDK8中对synchronized有了足够的优化吧。
HashTable虽然性能上不如ConcurrentHashMap,但并不能完全被取代,两者的迭代器的一致性不同的,hashTable的迭代器是强一致性的,而ConcurrentHashMap是弱一致的。
ConcurrentHashMap的get,clear,iterator 都是弱一致性的。 Doug Lea 也将这个判断留给用户自己决定是否使用ConcurrentHashMap。
ConcurrentHashMap与HashTable都可以用于多线程的环境,但是当HashTable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。因为ConcurrentHashMap引入了分割(segmentation),不论它变得多么大,仅仅需要锁定map的某个部分,而其它的线程不需要等到迭代完成才能访问map。简而言之,在迭代的过程中,ConcurrentHashMap仅仅锁定map的某个部分,而HashTable则会锁定整个map。
那么什么是强一致性和弱一致性呢?
get方法是弱一致的,是什么含义?可能你期望往ConcurrentHashMap底层数据结构中加入一个元素后,立马能对get可见,但ConcurrentHashMap并不能如你所愿。换句话说,put操作将一个元素加入到底层数据结构后,get可能在某段时间内还看不到这个元素,若不考虑内存模型,单从代码逻辑上来看,却是应该可以看得到的。
下面将结合代码和java内存模型相关内容来分析下put/get方法。put方法我们只需关注Segment#put,get方法只需关注Segment#get,在继续之前,先要说明一下Segment里有两个volatile变量:count和table;HashEntry里有一个volatile变量:value。
总结
ConcurrentHashMap的弱一致性主要是为了提升效率,是一致性与效率之间的一种权衡。要成为强一致性,就得到处使用锁,甚至是全局锁,这就与HashTable和同步的HashMap一样了。
HashMap 在并发执行 put 操作时会引起死循环,导致 CPU 利用率接近100%。因为多线程会导致 HashMap 的 Node 链表形成环形数据结构,一旦形成环形数据结构,Node 的 next 节点永远不为空,就会在获取 Node 时产生死循环。
了解了 HashMap 为什么线程不安全,那现在看看如何线程安全的使用 HashMap。这个无非就是以下三种方式:
Hashtable
ConcurrentHashMap
SynchronizedMap
Hashtable
例子
//Hashtable
Map<String, String> hashtable = new Hashtable<>();
//synchronizedMap
Map<String, String> synchronizedHashMap = Collections.synchronizedMap(new HashMap<String, String>());
//ConcurrentHashMap
Map<String, String> concurrentHashMap = new ConcurrentHashMap<>();
HashTable 源码中是使用synchronized来保证线程安全的,比如下面的 get 方法和 put 方法:
public synchronized V get(Object key) {
// 省略实现
}
public synchronized V put(K key, V value) {
// 省略实现
}
所以当一个线程访问 HashTable 的同步方法时,其他线程如果也要访问同步方法,会被阻塞住。举个例子,当一个线程使用 put 方法时,另一个线程不但不可以使用 put 方法,连 get 方法都不可以,好霸道啊!!!so~~,效率很低,现在基本不会选择它了。
ConcurrentHashMap
ConcurrentHashMap 于 Java 7 的,和8有区别,在8中 CHM 摒弃了 Segment(锁段)的概念,而是启用了一种全新的方式实现,利用 CAS 算法。
SynchronizedMap
synchronizedMap() 方法后会返回一个 SynchronizedMap 类的对象,而在 SynchronizedMap 类中使用了 synchronized 同步关键字来保证对 Map 的操作是线程安全的。
性能对比
这是要靠数据说话的时代,所以不能只靠嘴说 ConcurrentHashMap 快,它就快了。写个测试用例,实际的比较一下这三种方式的效率(源码来源),下面的代码分别通过三种方式创建 Map 对象,使用 ExecutorService 来并发运行5个线程,每个线程添加/获取500K个元素。
Test started for: class java.util.Hashtable
2500K entried added/retrieved in 2018 ms
2500K entried added/retrieved in 1746 ms
2500K entried added/retrieved in 1806 ms
2500K entried added/retrieved in 1801 ms
2500K entried added/retrieved in 1804 ms
For class java.util.Hashtable the average time is 1835 ms
Test started for: class java.util.Collections$SynchronizedMap
2500K entried added/retrieved in 3041 ms
2500K entried added/retrieved in 1690 ms
2500K entried added/retrieved in 1740 ms
2500K entried added/retrieved in 1649 ms
2500K entried added/retrieved in 1696 ms
For class java.util.Collections$SynchronizedMap the average time is 1963 ms
Test started for: class java.util.concurrent.ConcurrentHashMap
2500K entried added/retrieved in 738 ms
2500K entried added/retrieved in 696 ms
2500K entried added/retrieved in 548 ms
2500K entried added/retrieved in 1447 ms
2500K entried added/retrieved in 531 ms
For class java.util.concurrent.ConcurrentHashMap the average time is 792 ms
ConcurrentHashMap 性能是明显优于 Hashtable 和 SynchronizedMap 的,CHM 花费的时间比前两个的一半还少。
LinkedHashMap可以保证HashMap集合有序,存入的顺序和取出的顺序一致。
TreeMap实现SortMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。
HashMap不保证顺序,即为无序的,具有很快的访问速度。 HashMap最多只允许一条记录的键为Null;允许多条记录的值为 Null。 HashMap不支持线程的同步。
我们在开发的过程中使用HashMap比较多,在Map 中插入、删除和定位元素,HashMap 是最好的选择。
但如果您要按自然顺序或自定义顺序遍历键,那么TreeMap会更好。
如果需要输出的顺序和输入的相同,那么用LinkedHashMap 可以实现,它还可以按读取顺序来排列。
Collection 是集合类的上级接口,子接口主要有Set、List 、Map。
Collecions 是针对集合类的一个帮助类, 提供了操作集合的工具方法,一系列静态方法实现对各种集合的搜索、排序线性、线程安全化等操作。
例如
Map<String, Object> map4 = Collections.synchronizedMap(new HashMap<String, Object>()); //线程安全的HashMap
Collections.sort(List<T> list, Comparator<? super T> c); //排序List
Collection 是单列集合
List
元素是有序的、可重复。 有序的 collection,可以对列表中每个元素的插入位置进行精确地控制。 可以根据元素的整数索引(在列表中的位置)访问元素,并搜索列表中的元素。 可存放重复元素,元素存取是有序的。
List接口中常用类
Vector
线程安全,但速度慢,已被ArrayList替代。底层数据结构是数组结构。
ArrayList
线程不安全,查询速度快。底层数据结构是数组结构。
LinkedList
线程不安全。增删速度快。底层数据结构是列表结构。
Set
Set接口中常用的类
Set
元素无序的、不可重复。 取出元素的方法只有迭代器。不可以存放重复元素,元素存取是无序的。
HashSet
线程不安全,存取速度快。它是如何保证元素唯一性的呢?依赖的是元素的hashCode方法和euqals方法。
TreeSet
线程不安全,可以对Set集合中的元素进行排序。它的排序是如何进行的呢?通过compareTo或者compare方法中的来保证元素的唯一性。元素是以二叉树的形式存放的。
Map
map是一个双列集合
Hashtable
线程安全,速度快。底层是哈希表数据结构。是同步的。不允许null作为键,null作为值。
Properties
用于配置文件的定义和操作,使用频率非常高,同时键和值都是字符串。是集合中可以和IO技术相结合的对象。
HashMap
线程不安全,速度慢。底层也是哈希表数据结构。是不同步的。允许null作为键,null作为值,替代了Hashtable。
LinkedHashMap
可以保证HashMap集合有序。存入的顺序和取出的顺序一致。
TreeMap
可以用来对Map集合中的键进行排序
肯定会执行。finally{}块的代码。 只有在try{}块中包含遇到System.exit(0)。 之类的导致Java虚拟机直接退出的语句才会不执行。
当程序执行try{}遇到return时,程序会先执行return语句,但并不会立即返回——也就是把return语句要做的一切事情都准备好,也就是在将要返回、但并未返回的时候,程序把执行流程转去执行finally块,当finally块执行完成后就直接返回刚才return语句已经准备好的结果。
Throwable是 Java 语言中所有错误或异常的超类。 Throwable包含两个子类: Error 和 Exception 。它们通常用于指示发生了异常情况。 Throwable包含了其线程创建时线程执行堆栈的快照,它提供了printStackTrace()等接口用于获取堆栈跟踪数据等信息。
Java将可抛出(Throwable)的结构分为三种类型:
被检查的异常(Checked Exception)。 运行时异常(RuntimeException)。 错误(Error)。
运行时异常RuntimeException
定义 : RuntimeException及其子类都被称为运行时异常。 特点 : Java编译器不会检查它 也就是说,当程序中可能出现这类异常时,倘若既"没有通过throws声明抛出它",也"没有用try-catch语句捕获它",还是会编译通过。
例如,除数为零时产生的ArithmeticException异常,数组越界时产生的IndexOutOfBoundsException异常,fail-fail机制产生的ConcurrentModificationException异常等,都属于运行时异常。
堆内存溢出 OutOfMemoryError(OOM)
除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(OOM)异常的可能。
Java Heap 溢出。 一般的异常信息:java.lang.OutOfMemoryError:Java heap spacess。 java堆用于存储对象实例,我们只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,就会在对象数量达到最大堆容量限制后产生内存溢出异常。
堆栈溢出 StackOverflow (SOF)
StackOverflowError 的定义: 当应用程序递归太深而发生堆栈溢出时,抛出该错误。 因为栈一般默认为1-2m,一旦出现死循环或者是大量的递归调用,在不断的压栈过程中,造成栈容量超过1m而导致溢出。
栈溢出的原因:
递归调用。 大量循环或死循环。 全局变量是否过多。 数组、List、map数据过大。
封装(高内聚低耦合 -->解耦)
封装是指将某事物的属性和行为包装到对象中,这个对象只对外公布需要公开的属性和行为,而这个公布也是可以有选择性的公布给其它对象。在java中能使用private、protected、public三种修饰符或不用(即默认defalut)对外部对象访问该对象的属性和行为进行限制。
java的继承(重用父类的代码)
继承是子对象可以继承父对象的属性和行为,亦即父对象拥有的属性和行为,其子对象也就拥有了这些属性和行为。
java中的多态(父类引用指向子类对象)
多态是指父对象中的同一个行为能在其多个子对象中有不同的表现。
有两种多态的机制:编译时多态、运行时多态。
1、方法的重载:重载是指同一类中有多个同名的方法,但这些方法有着不同的参数。,因此在编译时就可以确定到底调用哪个方法,它是一种编译时多态。 2、方法的重写:子类可以覆盖父类的方法,因此同样的方法会在父类中与子类中有着不同的表现形式。
重载 Overload方法名相同,参数列表不同(个数、顺序、类型不同)与返回类型无关。 重写 Override 覆盖。 将父类的方法覆盖。 重写方法重写:方法名相同,访问修饰符只能大于被重写的方法访问修饰符,方法签名个数,顺序个数类型相同。
Override(重写)
Overload(重载)
而重载的规则
1、必须具有不同的参数列表。 2、可以有不同的返回类型,只要参数列表不同就可以了。 3、可以有不同的访问修饰符。 4、可以抛出不同的异常。
重写方法的规则
1、参数列表必须完全与被重写的方法相同,否则不能称其为重写而是重载。 2、返回的类型必须一直与被重写的方法的返回类型相同,否则不能称其为重写而是重载。 3、访问修饰符的限制一定要大于被重写方法的访问修饰符(public>protected>default>private)。 4、重写方法一定不能抛出新的检查异常或者比被重写方法申明更加宽泛的检查型异常。
例如: 父类的一个方法申明了一个检查异常IOException,在重写这个方法是就不能抛出Exception,只能抛出IOException的子类异常,可以抛出非检查异常。
Interface 只能有成员常量,只能是方法的声明。 Abstract class可以有成员变量,可以声明普通方法和抽象方法。
interface是接口,所有的方法都是抽象方法,成员变量是默认的public static final 类型。接口不能实例化自己。
abstract class是抽象类,至少包含一个抽象方法的累叫抽象类,抽象类不能被自身实例化,并用abstract关键字来修饰。
static class(内部静态类)
1、用static修饰的内部类,此时这个内部类变为静态内部类;对测试有用。 2、内部静态类不需要有指向外部类的引用。 3、静态内部类只能访问外部类的静态成员,不能访问外部类的非静态成员。
non static class(非静态内部类)
1、非静态内部类需要持有对外部类的引用。 2、非静态内部类能够访问外部类的静态和非静态成员。 3、一个非静态内部类不能脱离外部类实体被创建。 4、一个非静态内部类可以访问外部类的数据和方法。
用for循环arrayList 10万次花费时间:5毫秒。 用foreach循环arrayList 10万次花费时间:7毫秒。 用for循环linkList 10万次花费时间:4481毫秒。 用foreach循环linkList 10万次花费时间:5毫秒。
循环ArrayList时,普通for循环比foreach循环花费的时间要少一点。 循环LinkList时,普通for循环比foreach循环花费的时间要多很多。
当我将循环次数提升到一百万次的时候,循环ArrayList,普通for循环还是比foreach要快一点;但是普通for循环在循环LinkList时,程序直接卡死。
ArrayList
ArrayList是采用数组的形式保存对象的,这种方式将对象放在连续的内存块中,所以插入和删除时比较麻烦,查询比较方便。
LinkList
LinkList是将对象放在独立的空间中,而且每个空间中还保存下一个空间的索引,也就是数据结构中的链表结构,插入和删除比较方便,但是查找很麻烦,要从第一个开始遍历。
结论:
需要循环数组结构的数据时,建议使用普通for循环,因为for循环采用下标访问,对于数组结构的数据来说,采用下标访问比较好。
需要循环链表结构的数据时,一定不要使用普通for循环,这种做法很糟糕,数据量大的时候有可能会导致系统崩溃。
NIO是为了弥补IO操作的不足而诞生的,NIO的一些新特性有:非阻塞I/O,选择器,缓冲以及管道。管道(Channel),缓冲(Buffer) ,选择器( Selector)是其主要特征。
概念解释
Channel——管道实际上就像传统IO中的流,到任何目的地(或来自任何地方)的所有数据都必须通过一个 Channel 对象。一个 Buffer 实质上是一个容器对象。
每一种基本 Java 类型都有一种缓冲区类型:
ByteBuffer——byte
CharBuffer——char
ShortBuffer——short
IntBuffer——int
LongBuffer——long
FloatBuffer——float
DoubleBuffer——double
Selector
——选择器用于监听多个管道的事件,使用传统的阻塞IO时我们可以方便的知道什么时候可以进行读写,而使用非阻塞通道,我们需要一些方法来知道什么时候通道准备好了,选择器正是为这个需要而诞生的。
NIO和传统的IO有什么区别呢?
IO是面向流的,NIO是面向块(缓冲区)的。
IO面向流的操作一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。,导致了数据的读取和写入效率不佳。
NIO面向块的操作在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多,同时数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。通俗来说,NIO采取了“预读”的方式,当你读取某一部分数据时,他就会猜测你下一步可能会读取的数据而预先缓冲下来。
IO是阻塞的,NIO是非阻塞的
对于传统的IO,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。
而对于NIO,使用一个线程发送读取数据请求,没有得到响应之前,线程是空闲的,此时线程可以去执行别的任务,而不是像IO中那样只能等待响应完成。
NIO和IO适用场景
NIO是为弥补传统IO的不足而诞生的,但是尺有所短寸有所长,NIO也有缺点,因为NIO是面向缓冲区的操作,每一次的数据处理都是对缓冲区进行的,那么就会有一个问题,在数据处理之前必须要判断缓冲区的数据是否完整或者已经读取完毕,如果没有,假设数据只读取了一部分,那么对不完整的数据处理没有任何意义。所以每次数据处理之前都要检测缓冲区数据。
那么NIO和IO各适用的场景是什么呢?
如果需要管理同时打开的成千上万个连接,这些连接每次只是发送少量的数据,例如聊天服务器,这时候用NIO处理数据可能是个很好的选择。
而如果只有少量的连接,而这些连接每次要发送大量的数据,这时候传统的IO更合适。使用哪种处理数据,需要在数据的响应等待时间和检查缓冲区数据的时间上作比较来权衡选择。
通俗解释,最后,对于NIO和传统IO
有一个网友讲的生动的例子:
以前的流总是堵塞的,一个线程只要对它进行操作,其它操作就会被堵塞,也就相当于水管没有阀门,你伸手接水的时候,不管水到了没有,你就都只能耗在接水(流)上。
nio的Channel
的加入,相当于增加了水龙头(有阀门),虽然一个时刻也只能接一个水管的水,但依赖轮换策略,在水量不大的时候,各个水管里流出来的水,都可以得到妥善接纳,这个关键之处就是增加了一个接水工,也就是Selector
,他负责协调,也就是看哪根水管有水了的话,在当前水管的水接到一定程度的时候,就切换一下:临时关上当前水龙头,试着打开另一个水龙头(看看有没有水)。
当其他人需要用水的时候,不是直接去接水,而是事前提了一个水桶给接水工,这个水桶就是Buffer
。也就是,其他人虽然也可能要等,但不会在现场等,而是回家等,可以做其它事去,水接满了,接水工会通知他们。
这其实也是非常接近当前社会分工细化的现实,也是统分利用现有资源达到并发效果的一种很经济的手段,而不是动不动就来个并行处理,虽然那样是最简单的,但也是最浪费资源的方式。
什么是Java的反射呢?
Java 反射是可以让我们在运行时,通过一个类的Class对象来获取它获取类的方法、属性、父类、接口等类的内部信息的机制。
这种动态获取信息以及动态调用对象的方法的功能称为JAVA的反射。
反射的作用?
反射就是:在任意一个方法里:
1.如果我知道一个类的名称/或者它的一个实例对象, 我就能把这个类的所有方法和变量的信息找出来(方法名,变量名,方法,修饰符,类型,方法参数等等所有信息)
2.如果我还明确知道这个类里某个变量的名称,我还能得到这个变量当前的值。
3.当然,如果我明确知道这个类里的某个方法名+参数个数类型,我还能通过传递参数来运行那个类里的那个方法。
反射机制主要提供了以下功能:
反射的原理?
JAVA语言编译之后会生成一个.class文件,反射就是通过字节码文件找到某一个类、类中的方法以及属性等。
反射的实现API有哪些?
反射的实现主要借助以下四个类:
Class:类的对象
Constructor:类的构造方法
Field:类中的属性对象
Method:类中的方法对象
前2者都需要显式地调用构造方法,造成耦合性最高的恰好是第一种,因此你发现无论什么框架,只要涉及到解耦必先减少new的使用。
intern()方法会首先从常量池中查找是否存在该常量值,如果常量池中不存在则现在常量池中创建,如果已经存在则直接返回。
例子:
String s1=”aa”;
String s2=s1.intern();
System.out.print(s1==s2);//返回true
+=操作符会进行隐式自动类型转换,此处a+=b隐式的将加操作的结果类型强制转换为持有结果的类型,而a=a+b则不会自动进行类型转换。
例子:
byte a = 127;
byte b = 127;
b = a + b; // error : cannot convert from int to byte
b += a; // ok
有错误,short类型在进行运算时会自动提升为int类型,也就是说s1+1
的运算结果是int类型。
&是
位操作,而&&
是逻辑运算符。
&&
逻辑运算符具有短路特性,而&
不具备短路特性。
public class Test{
static String name;
public static void main(String[] args){
if(name!=null&userName.equals("")){
System.out.println("ok");
}else{
System.out.println("erro");
}
}
}
Integer 对象会占用更多的内存。Integer是一个对象,需要存储对象的元数据。但是 int 是一个原始类型的数据,所以占用的空间更少。
参考 https://www.cnblogs.com/java1024/p/8622195.html
Map接口和Collection接口是所有集合框架的父接口:
HashMap没有考虑同步,是线程不安全的;HashTable使用了synchronized关键字,是线程安全的。
HashMap允许K/V都为null;后者K/V都不允许为null。
HashMap继承自AbstractMap类,而HashTable继承自Dictionary类。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
// 1.如果table为空或者长度为0,即没有元素,那么使用resize()方法扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2.计算插入存储的数组索引i,此处计算方法同 1.7 中的indexFor()方法
// 如果数组为空,即不存在Hash冲突,则直接插入数组
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 3.插入时,如果发生Hash冲突,则依次往下判断
else {
HashMap.Node<K,V> e; K k;
// a.判断table[i]的元素的key是否与需要插入的key一样,若相同则直接用新的value覆盖掉旧的value
// 判断原则equals() - 所以需要当key的对象重写该方法
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// b.继续判断:需要插入的数据结构是红黑树还是链表
// 如果是红黑树,则直接在树中插入 or 更新键值对
else if (p instanceof HashMap.TreeNode)
e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 如果是链表,则在链表中插入 or 更新键值对
else {
// i .遍历table[i],判断key是否已存在:采用equals对比当前遍历结点的key与需要插入数据的key
// 如果存在相同的,则直接覆盖
// ii.遍历完毕后任务发现上述情况,则直接在链表尾部插入数据
// 插入完成后判断链表长度是否 > 8:若是,则把链表转换成红黑树
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 对于i 情况的后续操作:发现key已存在,直接用新value覆盖旧value&返回旧value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 插入成功后,判断实际存在的键值对数量size > 最大容量
// 如果大于则进行扩容
if (++size > threshold)
resize();
// 插入成功时会调用的方法(默认实现为空)
afterNodeInsertion(evict);
return null;
}
图片总结为:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-We5LxGD9-1606397355996)(https://s2.ax1x.com/2019/06/05/VUYrJf.png)]
通过分析源码我们知道了HashMap通过resize()
方法进行扩容或者初始化的操作,下面是对源码进行的一些简单分析:
/**
* 该函数有2中使用情况:1.初始化哈希表;2.当前数组容量过小,需要扩容
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;// 扩容前的数组(当前数组)
int oldCap = (oldTab == null) ? 0 : oldTab.length;// 扩容前的数组容量(数组长度)
int oldThr = threshold;// 扩容前数组的阈值
int newCap, newThr = 0;
if (oldCap > 0) {
// 针对情况2:若扩容前的数组容量超过最大值,则不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 针对情况2:若没有超过最大值,就扩容为原来的2倍(左移1位)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 针对情况1:初始化哈希表(采用指定或者使用默认值的方式)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 把每一个bucket都移动到新的bucket中去
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
在解决这个问题之前,我们首先需要知道什么是哈希冲突,而在了解哈希冲突之前我们还要知道什么是哈希才行;
什么是哈希?
**Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值);**这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。
什么是哈希冲突?
当两个不同的输入值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希碰撞)。
HashMap的数据结构
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做链地址法的方式可以解决哈希冲突:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7Mt488Pn-1606397356000)(https://s2.ax1x.com/2019/06/05/VUYHlF.png)]
这样我们就可以将拥有相同哈希值的对象组织成一个链表放在hash值所对应的bucket下,**但相比于hashCode返回的int类型,我们HashMap初始的容量大小DEFAULT_INITIAL_CAPACITY = 1 << 4
(即2的四次方16)要远小于int类型的范围,所以我们如果只是单纯的用hashCode取余来获取对应的bucket这将会大大增加哈希碰撞的概率,并且最坏情况下还会将HashMap变成一个单链表,**所以我们还需要对hashCode作一定的优化
hash()函数
上面提到的问题,主要是因为如果使用hashCode取余,那么相当于参与运算的只有hashCode的低位,高位是没有起到任何作用的,所以我们的思路就是让hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在JDK 1.8中的hash()函数如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 与自己右移16位进行异或运算(高低位异或)
}
这比在JDK 1.7中,更为简洁,相比在1.7中的4次位运算,5次异或运算(9次扰动),在1.8中,只进行了1次位运算和1次异或运算(2次扰动);
JDK1.8新增红黑树
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T4WGP45d-1606397356001)(https://s2.ax1x.com/2019/06/05/VUtETI.png)]
总结
简单总结一下HashMap是使用了哪些方法来有效解决哈希冲突的:
1. 使用链地址法(使用散列表)来链接拥有相同hash值的数据;
2. 使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均;
3. 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快;
hashCode()
方法返回的是int整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值)~2 ^ 30,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()
计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置;
面试官:那怎么解决呢?
HashMap自己实现了自己的hash()
方法,通过两次扰动使得它自己的哈希值高低位自行进行异或运算,降低哈希碰撞概率也使得数据分布更平均。
在保证数组长度为2的幂次方的时候,使用hash()
运算之后的值与运算(&)(数组长度 - 1)来获取数组下标的方式进行存储,这样一来是比取余操作更加有效率,二来也是因为只有当数组长度为2的幂次方时,h&(length-1)才等价于h%length,三来解决了“哈希值与数组大小范围不匹配”的问题。
面试官:为什么数组长度要保证为2的幂次方呢?
只有当数组长度为2的幂次方时,h&(length-1)才等价于h%length,即实现了key的定位,2的幂次方也可以减少冲突次数,提高HashMap的查询效率。
如果 length 为 2 的次幂 则 length-1 转化为二进制必定是 11111……的形式,在于 h 的二进制与操作效率会非常的快,而且空间不浪费;如果 length 不是 2 的次幂,比如 length 为 15,则 length - 1 为 14,对应的二进制为 1110,在于 h 与操作,最后一位都为 0 ,而 0001,0011,0101,1001,1011,0111,1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。
面试官:那为什么是两次扰动呢?
这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的。
不同 | JDK 1.7 | JDK 1.8 |
---|---|---|
存储结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
初始化方式 | 单独函数:inflateTable() | 直接集成到了扩容函数resize()中 |
hash值计算方式 | 扰动处理 = 9次扰动 = 4次位运算 + 5次异或运算 | 扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算 |
存放数据的规则 | 无冲突时,存放数组;冲突时,存放链表 | 无冲突时,存放数组;冲突 & 链表长度 < 8:存放单链表;冲突 & 链表长度 > 8:树化并存放红黑树 |
插入数据方式 | 头插法(先讲原位置的数据移到后1位,再插入数据到该位置) | 尾插法(直接插入到链表尾部/红黑树) |
扩容后存储位置的计算方式 | 全部按照原来方法进行计算(即hashCode ->> 扰动函数 ->> (h&length-1)) | 按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量) |
String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率
equals()
、hashCode()
等方法,遵守了HashMap内部的规范(不清楚可以去上面看看putValue的过程),不容易出现Hash值计算错误的情况;面试官:如果我想要让自己的Object作为K应该怎么办呢?
重写hashCode()
和equals()
方法
重写hashCode()
是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞。
重写equals()
方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性。
ConcurrentHashMap 结合了 HashMap 和 HashTable 二者的优势。HashMap 没有考虑同步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。 ConcurrentHashMap 锁的方式是稍微细粒度的。
面试官:ConcurrentHashMap的具体实现知道吗?
在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的方式进行实现,结构如下:
该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。
在JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,结构如下:
插入元素过程(建议去看看源码):
如果相应位置的Node还没有初始化,则调用CAS插入相应的数据。
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
如果相应位置的Node不为空,且当前该节点不处于移动状态,则对该节点加synchronized锁,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点。
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
break;
}
}
}
如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红黑树中插入节点;如果binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin方法转化为红黑树,如果oldVal不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值。
如果插入的是一个新节点,则执行addCount()方法尝试更新元素个数baseCount。
是java集合的一种错误检测机制,当多个线程对集合进行结构上的改变的操作时,有可能会产生 fail-fast 机制。
例如:假设存在两个线程(线程1、线程2),线程1通过Iterator在遍历集合A中的元素,在某个时候线程2修改了集合A的结构(是结构上面的修改,而不是简单的修改集合元素的内容),那么这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生fail-fast机制。
原因:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
解决办法:
1. 在遍历过程中,所有涉及到改变modCount值得地方全部加上synchronized。
2. 使用CopyOnWriteArrayList来替换ArrayList
这两个类都实现了 List 接口(List 接口继承了 Collection 接口),他们都是有序集合,即存储在这两个集合中的元素位置都是有顺序的,相当于一种动态的数组,我们以后可以按位置索引来取出某个元素,并且其中的数据是允许重复的,这是与 HashSet 之类的集合的最大不同处,HashSet 之类的集合不可以按索引号去检索其中的元素,也不允许有重复的元素。
ArrayList 与 Vector 的区别主要包括两个方面:
同步性:
Vector 是线程安全的,也就是说它的方法之间是线程同步(加了synchronized 关键字)的,而 ArrayList 是线程不安全的,它的方法之间是线程不同步的。如果只有一个线程会访问到集合,那最好是使用 ArrayList,因为它不考虑线程安全的问题,所以效率会高一些;如果有多个线程会访问到集合,那最好是使用 Vector,因为不需要我们自己再去考虑和编写线程安全的代码。
数据增长:
ArrayList 与 Vector 都有一个初始的容量大小,当存储进它们里面的元素的个人超过了容量时,就需要增加 ArrayList 和 Vector 的存储空间,每次要增加存储空间时,不是只增加一个存储单元,而是增加多个存储单元,每次增加的存储单元的个数在内存空间利用与程序效率之间要去的一定的平衡。Vector 在数据满时(加载因子1)增长为原来的两倍(扩容增量:原容量的 2 倍),而 ArrayList 在数据量达到容量的一半时(加载因子 0.5)增长为原容量的 (0.5 倍 + 1) 个空间。
面试官:Array 和 ArrayList 有什么区别?什么时候该应 Array 而不是 ArrayList 呢?
它们的区别是:
对于基本类型数据,集合使用自动装箱来减少编码工作量。但是,当处理固定大小的基本数据类型的时候,这种方式相对比较慢。
HashSet的底层其实就是HashMap,只不过我们HashSet是实现了Set接口并且把数据作为K值,而V值一直使用一个相同的虚值来保存,我们可以看到源码:
public boolean add(E e) {
return map.put(e, PRESENT)==null;// 调用HashMap的put方法,PRESENT是一个至始至终都相同的虚值
}
由于HashMap的K值本身就不允许重复,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V,那么在HashSet中执行这一句话始终会返回一个false,导致插入失败,这样就保证了数据的不可重复性。
Java.util.concurrent.BlockingQueue是一个队列,在进行检索或移除一个元素的时候,它会等待队列变为非空;当在添加一个元素时,它会等待队列中的可用空间。BlockingQueue接口是Java集合框架的一部分,主要用于实现生产者-消费者模式。我们不需要担心等待生产者有可用的空间,或消费者有可用的对象,因为它都在BlockingQueue的实现类中被处理了。Java提供了集中BlockingQueue的实现,比如ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue,、SynchronousQueue等。
参考 https://www.jianshu.com/p/939b8a672070
线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。程序员可以通过它进行多处理器编程,你可以使用多线程对运算密集型任务提速。比如,如果一个线程完成一个任务要100毫秒,那么用十个线程完成该任务只需10毫秒。
一个进程是一个独立(self contained)的运行环境,它可以被看作一个程序或者一个应用。而线程是在进程中执行的一个任务。线程是进程的子集,一个进程可以有很多线程,每条线程并行执行不同的任务。不同的进程使用不同的内存空间,而所有的线程共享一片相同的内存空间。别把它和栈内存搞混,每个线程都拥有单独的栈内存用来存储本地数据。
有两种创建线程的方法:一是实现Runnable接口,然后将它传递给Thread的构造函数,创建一个Thread对象;二是直接继承Thread类。
这个问题是上题的后续,大家都知道我们可以通过继承Thread类或者调用Runnable接口来实现线程,问题是,那个方法更好呢?什么情况下使用它?这个问题很容易回答,如果你知道Java不支持类的多重继承,但允许你调用多个接口。所以如果你要继承其他类,当然是调用Runnable接口好了。更多详细信息请点击这里。
start()方法被用来启动新创建的线程,使该被创建的线程状态变为可运行状态。当你调用run()方法的时候,只会是在原来的线程中调用,没有新的线程启动,start()方法才会启动新线程。如果我们调用了Thread的run()方法,它的行为就会和普通的方法一样,直接运行run()方法。为了在新的线程中执行我们的代码,必须使用Thread.start()方法。
Runnable和Callable都代表那些要在不同的线程中执行的任务。Runnable从JDK1.0开始就有了,Callable是在JDK1.5增加的。它们的主要区别是Callable的 call() 方法可以返回值和抛出异常,而Runnable的run()方法没有这些功能。Callable可以返回装载有计算结果的Future对象。
CyclicBarrier 和 CountDownLatch 都可以用来让一组线程等待其它线程。
与 CyclicBarrier 不同的是,CountdownLatch 不能重新使用。
Java内存模型规定和指引Java程序在不同的内存架构、CPU和操作系统间有确定性地行为。它在多线程的情况下尤其重要。Java内存模型对一个线程所做的变动能被其它线程可见提供了保证,它们之间是先行发生关系。这个关系定义了一些规则让程序员在并发编程时思路更清晰。比如,先行发生关系确保了。
如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。一个线程安全的计数器类的同一个实例对象在被多个线程使用的情况下也不会出现计算失误。很显然你可以将集合类分成两组,线程安全和非线程安全的。Vector 是用同步方法来实现线程安全的, 而和它相似的ArrayList不是线程安全的。
在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果i线程存取相同的对象,并且每一个线程都调用了一个修改该对象状态的方法,将会发生什么呢?可以想象,线程彼此踩了对方的脚。根据线程访问数据的次序,可能会产生讹误的对象。这样的情况通常称为竞争条件。
Java提供了很丰富的API但没有为停止线程提供API。JDK 1.0本来有一些像stop(), suspend() 和 resume()的控制方法,但是由于潜在的死锁威胁。因此在后续的JDK版本中他们被弃用了,之后Java API的设计者就没有提供一个兼容且线程安全的方法来停止一个线程。当run() 或者 call() 方法执行完的时候线程会自动结束,如果要手动结束一个线程,可以用volatile 布尔变量来退出run()方法的循环或者是取消任务来中断线程。
如果异常没有被捕获该线程将会停止执行。Thread.UncaughtExceptionHandler是用于处理未捕获异常造成线程突然中断情况的一个内嵌接口。当一个未捕获异常将造成线程中断的时候JVM会使用Thread.getUncaughtExceptionHandler()来查询线程的UncaughtExceptionHandler并将线程和异常作为参数传递给handler的uncaughtException()方法进行处理。
这又是一个刁钻的问题,因为多线程可以等待单监控锁,Java API 的设计人员提供了一些方法当等待条件改变的时候通知它们,但是这些方法没有完全实现。notify()方法不能唤醒某个具体的线程,所以只有一个线程在等待的时候它才有用武之地。而notifyAll()唤醒所有线程并允许他们争夺锁确保了至少有一个线程能继续运行。
一个很明显的原因是JAVA提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。如果线程需要等待某些锁那么调用对象中的wait()方法就有意义了。如果wait()方法定义在Thread类中,线程正在等待的是哪个锁就不明显了。简单的说,由于wait,notify和notifyAll都是锁级别的操作,所以把他们定义在Object类中因为锁属于对象。
ThreadLocal是Java里一种特殊的变量。每个线程都有一个ThreadLocal就是每个线程都拥有了自己独立的一个变量,竞争条件被彻底消除了。如果为每个线程提供一个自己独有的变量拷贝,将大大提高效率。首先,通过复用减少了代价高昂的对象的创建个数。其次,你在没有使用高代价的同步或者不变性的情况下获得了线程安全。
在Java并发程序中FutureTask表示一个可以取消的异步运算。它有启动和取消运算、查询运算是否完成和取回运算结果等方法。只有当运算完成的时候结果才能取回,如果运算尚未完成get方法将会阻塞。一个FutureTask对象可以对调用了Callable和Runnable的对象进行包装,由于FutureTask也是调用了Runnable接口所以它可以提交给Executor来执行。
interrupted() 和 isInterrupted()的主要区别是前者会将中断状态清除而后者不会。Java多线程的中断机制是用内部标识来实现的,调用Thread.interrupt()来中断一个线程就会设置中断标识为true。当中断线程调用静态方法Thread.interrupted()来检查中断状态时,中断状态会被清零。而非静态方法isInterrupted()用来查询其它线程的中断状态且不会改变中断状态标识。简单的说就是任何抛出InterruptedException异常的方法都会将中断状态清零。无论如何,一个线程的中断状态有有可能被其它线程调用中断来改变。
当一个线程需要调用对象的wait()方法的时候,这个线程必须拥有该对象的锁,接着它就会释放这个对象锁并进入等待状态直到其他线程调用这个对象上的notify()方法。同样的,当一个线程需要调用对象的notify()方法时,它会释放这个对象的锁,以便其他在等待的线程就可以得到这个对象锁。由于所有的这些方法都需要线程持有对象的锁,这样就只能通过同步来实现,所以他们只能在同步方法或者同步块中被调用。如果你不这么做,代码会抛出IllegalMonitorStateException异常。
同步集合与并发集合都为多线程和并发提供了合适的线程安全的集合,不过并发集合的可扩展性更高。在Java1.5之前程序员们只有同步集合来用且在多线程并发的时候会导致争用,阻碍了系统的扩展性。Java5介绍了并发集合像ConcurrentHashMap,不仅提供线程安全还用锁分离和内部分区等现代技术提高了可扩展性。
为什么把这个问题归类在多线程和并发面试题里?因为栈是一块和线程紧密相关的内存区域。每个线程都有自己的栈内存,用于存储本地变量,方法参数和栈调用,一个线程中存储的变量对其它线程是不可见的。而堆是所有线程共享的一片公用内存区域。对象都在堆里创建,为了提升效率线程会从堆中弄一个缓存到自己的栈,如果多个线程使用该变量就可能引发问题,这时volatile 变量就可以发挥作用了,它要求线程从主存中读取变量的值。
在现实中你解决的许多线程问题都属于生产者消费者模型,就是一个线程生产任务供其它线程进行消费,你必须知道怎么进行线程间通信来解决这个问题。比较低级的办法是用wait和notify来解决这个问题,比较赞的办法是用Semaphore 或者 BlockingQueue来实现生产者消费者模型。
Java多线程中的死锁
死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。这是一个严重的问题,因为死锁会让你的程序挂起无法完成任务,死锁的发生必须满足以下四个条件:
避免死锁最简单的方法就是阻止循环等待条件,将系统中所有的资源设置标志位、排序,规定所有的进程申请资源必须以一定的顺序(升序或降序)做操作来避免死锁。
这是上题的扩展,活锁和死锁类似,不同之处在于处于活锁的线程或进程的状态是不断改变的,活锁可以认为是一种特殊的饥饿。一个现实的活锁例子是两个人在狭小的走廊碰到,两个人都试着避让对方好让彼此通过,但是因为避让的方向都一样导致最后谁都不能通过走廊。简单的说就是,活锁和死锁的主要区别是前者进程的状态可以改变但是却不能继续执行。
在java.lang.Thread中有一个方法叫holdsLock(),它返回true如果当且仅当当前线程拥有某个具体对象的锁。
这个问题很简单, -Xss参数用来控制线程的堆栈大小。你可以查看JVM配置列表来了解这个参数的更多信息。
Java在过去很长一段时间只能通过synchronized关键字来实现互斥,它有一些缺点。比如你不能扩展锁之外的方法或者块边界,尝试获取锁时不能中途取消等。Java 5 通过Lock接口提供了更复杂的控制来解决这些问题。 ReentrantLock 类实现了 Lock,它拥有与 synchronized 相同的并发性和内存语义且它还具有可扩展性。
yield方法可以暂停当前正在执行的线程对象,让其它有相同优先级的线程执行。它是一个静态方法而且只保证当前线程放弃CPU占用而不能保证使其它线程一定能占用CPU,执行yield()的线程有可能在进入到暂停状态后马上又被执行。
Java中的Semaphore是一种新的同步类,它是一个计数信号。从概念上讲,从概念上讲,信号量维护了一个许可集合。如有必要,在许可可用前会阻塞每一个 acquire(),然后再获取该许可。每个 release()添加一个许可,从而可能释放一个正在阻塞的获取者。但是,不使用实际的许可对象,Semaphore只对可用许可的号码进行计数,并采取相应的行动。信号量常常用于多线程的代码中,比如数据库连接池。
这个问题问得很狡猾,许多程序员会认为该任务会阻塞直到线程池队列有空位。事实上如果一个任务不能被调度执行那么ThreadPoolExecutor’s submit()方法将会抛出一个RejectedExecutionException异常。
两个方法都可以向线程池提交任务,execute()方法的返回类型是void,它定义在Executor接口中, 而submit()方法可以返回持有计算结果的Future对象,它定义在ExecutorService接口中,它扩展了Executor接口,其它线程池类像ThreadPoolExecutor和ScheduledThreadPoolExecutor都有这些方法。
每一个线程都是有优先级的,一般来说,高优先级的线程在运行时会具有优先权,但这依赖于线程调度的实现,这个实现是和操作系统相关的(OS dependent)。我们可以定义线程的优先级,但是这并不能保证高优先级的线程会在低优先级的线程前执行。线程优先级是一个int变量(从1-10),1代表最低优先级,10代表最高优先级。
一般而言,读写锁是用来提升并发程序性能的锁分离技术的成果。Java中的ReadWriteLock是Java 5 中新增的一个接口,一个ReadWriteLock维护一对关联的锁,一个用于只读操作一个用于写。在没有写线程的情况下一个读锁可能会同时被多个读线程持有。写锁是独占的,你可以使用JDK中的ReentrantReadWriteLock来实现这个规则,它最多支持65535个写锁和65535个读锁。
这个问题在Java面试中经常被问到,但是面试官对回答此问题的满意度仅为50%。一半的人写不出双检锁还有一半的人说不出它的隐患和Java1.5是如何对它修正的。它其实是一个用来创建线程安全的单例的老方法,当单例实例第一次被创建时它试图用单个锁进行性能优化,但是由于太过于复杂在JDK1.4中它是失败的。
这是上面那个问题的后续,如果你不喜欢双检锁而面试官问了创建Singleton类的替代方法,你可以利用JVM的类加载和静态变量初始化特征来创建Singleton实例,或者是利用枚举类型来创建Singleton。
当线程间是可以共享资源时,线程间通信是协调它们的重要的手段。Object类中wait()\notify()\notifyAll()方法可以用于线程间通信关于资源的锁的状态。
使用Thread类的setDaemon(true)方法可以将线程设置为守护线程,需要注意的是,需要在调用start()方法前调用这个方法,否则会抛出IllegalThreadStateException异常。
同步块是更好的选择,因为它不会锁住整个对象(当然你也可以让它锁住整个对象)。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁。
锁升级顺序
首先是无锁,判断是否要加锁,加锁后,升级为偏向锁,偏向锁意思为如果一个线程获得锁,再次请求的时候不需要再去获得锁,如果还有其他线程来访问,就升级为轻量级锁,通过CAS去获得锁,如果CAS失败,自旋到一定次数后如果还没有成功就升级为重量级锁。(自旋超过了限定次数,默认是10次没有成功获得锁,就应当挂起线程)
Volatile 是Java虚拟机提供的轻量级的同步机制
1.保证可见性
2.不保证原子性
3.禁止指令重排(有序性)
因为修改volatile变量分为以下四步:
读取volatile变量到本地内存。
修改变量值
将值写回主内存。
插入内存屏障,即Lock指令,让其他线程可见。
因为在前三步不是线程安全的,不能保证取值和写回之间没有被其他线程修改。原子性需要锁来保证。
1.使用synchronized
2.使用Atomic原子类
因为使用了CAS。
CAS是:比较并交换
boolean AtomicInteger.compareAndSwap(期望值,更新值)
是一条CPU并发原语。
使用Unsafe类,大多数使用native修饰的方法。
Unsafe类根据内存偏移地址去获取数据。
ABA问题就是在主内存中原本是A后来有另外一个线程修改为了B后又改回了A,第一个线程回来看后还是A以为没有变化,实际上已经有了变化。
如何解决ABA问题?
AtomicStampedReference
增加版本号,进行版本号判断。
execute和submit都属于线程池的方法,execute只能提交Runnable类型的任务,而submit既能提交Runnable类型任务也能提交Callable类型任务。
execute会直接抛出任务执行时的异常,submit会吃掉异常,可通过Future的get方法将任务执行时的异常重新抛出。
execute所属顶层接口是Executor,submit所属顶层接口是ExecutorService,实现类ThreadPoolExecutor重写了execute方法,抽象类AbstractExecutorService重写了submit方法。
允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
允许创建的线程数量为Integer.MAX_VALUE,可能会堆积大量的线程,从而导致OOM。
public static void main(String[] args) {
ExecutorService theadPool = new ThreadPoolExecutor(
2,
5,
1,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(3),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()); //默认拒绝策略
try {
for (int i = 0; i < 30; i++) {
theadPool.execute(() -> System.out.println(Thread.currentThread().getName() +"来办理业务"));
}
}catch (Exception e) {
e.printStackTrace();
}finally {
theadPool.shutdown();
}
}
好处是我们不需要再关心什么时候阻塞线程,什么时候唤醒线程,阻塞队列会在满的时候自动阻塞添加线程。在空的时候阻塞获取线程。一旦阻塞队列不再满了,自动唤醒添加线程进行添加。
使用while,不要使用if。因为while会在唤醒后重新走一下条件判断。
GC roots就是一组必须活跃的引用
java使用了可达性分析的方法来判断对象是否是垃圾。
基本思路就是从GC Roots对象作为起点向下搜索,如果一个对象到GC Roots没有任何的引用链时候,则说明对象不可用。
虚拟机栈。 方法区中的类静态属性的对象。 方法区中常量引用的对象。 本地方法栈中native方法引用的对象。
java -XX:+PrintFlagsInitial 查看初始值 java -XX:+PrintFlagsFinal 查看最终值
java -XX:+PrintFlagsFinal -XX:MetaspaceSize=512m 运行类名
Eden 空间占满了, 会触发 Young GC
Minor(Scavenge) GC 后仍然存活的对象会被复制到 S0 (survivor 0)中去。 这样 Eden 就被清空可以分配给新的对象。 又触发了一次 Minor GC,
S0 和 Eden 中存活的对象被复制到 S1 中, 并且 S0和 Eden 被清空。 在同一时刻, 只有 Eden 和一个 Survivor Space 同时被操作。 当每次对象从 Eden 复制到 Survivor Space 或者从 Survivor Space 中的一个复制到另外一个, 有一个计数器会自动增加值。 默认情况下如果复制发生超过 15.次, JVM 会停止复制并把他们移到老年代中去. 同样的如果一个对象不能在 Eden 中被创建, 它会直接被创建在老年代中。
FulGC :
类的加载分为 加载、链接 (包括验证、准备、解析三步)、初始化。
加载是文件到内存的过程。通过类的完全限定名查找此类字节码文件,并利用字节码文件创建一个Class对象。
验证是否符合虚拟机要求,保证不会危害虚拟机自身安全,包含四种:文件格式验证,元数据验证,字节码验证,符号引用验证。
准备阶段是进行内存分配。为类变量也就是类中由static修饰的变量分配内存,并且设置初始值,这里要注意,初始值是0或者null,而不是代码中设置的具体值,代码中设置的值是在初始化阶段完成的。另外这里也不包含用final修饰的静态变量,因为final在编译的时候就会分配了。
解析主要是解析字段、接口、方法。主要是将常量池中的符号引用替换为直接引用的过程。直接引用就是直接指向目标的指针、相对偏移量等。
初始化就是完成对静态块和静态变量赋值。
①类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
举个例子,我们的string就算是对象创建,既是 String str= new String(“abc”); 首先也会在常量池中定位是否存在值。若不存在,则会在常量池也建立一个abc.
②分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
③初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
④设置对象头: 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
⑤执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
引用计数法
复制算法
年轻代中Minor GC使用的就是复制算法。
标记清除(Mark-Sweep)
老年代 一般是由标记清除,或者是标记清除和标记整理实现。
先扫描需要被回收的对象,然后标记起来,进行整体清除。
优点:不需要额外的空间。
缺点:会造成内存碎片。
标记压缩(Mark-Compact)
老年代 一般是由标记清除,或者是标记清除和标记整理实现。
java -XX:+PrintCommandLineFlags -version
只使用一个线程进行垃圾回收,会暂停所有用户线程。是java虚拟机运行在32位client模式下的默认新生代垃圾收集器。
优点:简单高效。
缺点:需要暂停其他线程。
对应的JVM参数是-XX:+UseSerialGC
开启后就会在Young区和Old区使用。
新生代使用的是复制算法,老年代使用的是标记整理(压缩)算法。
使用多线程进行垃圾回收,在垃圾收集时,会Stop-The-World暂停其他所有工作线程直到他收集结束。
ParNew收集器其实就是Serial新生代
的多线程版本老年代还是单线程。
对应的JVM参数是-XX:+UseParNewGC
开启后只影响新生代的收集,不影响老年代。
新生代使用的是复制算法,老年代使用的是标记整理(压缩)算法。
是一个新生代的垃圾收集器,使用的是复制算法。
在新生代和老年代都是并行化。
JDK8使用的就是 Parallel + Parallel Old 收集器
UseParallelGC 即 Parallel Scavenge 和 Parallel Old。
对应的JVM参数是-XX:+UseParallelGC 或者 -XX:+UseParallelOldGC
开启该参数后:新生代使用的是复制算法,老年代使用的是标记整理(压缩)算法。
是一种以获取最短回收停顿时间为目标的收集器。
并发收集低停顿,可以和用户线程一起执行。
对应的JVM参数是-XX:+UseConcMarkSweepGC 开启后自动将-XX:+UseParNewGC打开。
使用ParNew(Young区)+CMS(Old区)+ Serial Old收集器组合,Serial Old 将作为CMS出错的后备收集器。
CMS的四步过程:
优点:并发收集低停顿
缺点:并发执行,对CPU资源压力大。采用的是标记清除算法,会产生内存碎片。
是一个可以在年轻代和老年代一起使用的垃圾收集器,可以并发执行,设计的目的是为了代替CMS收集器
G1采用的是标记整理(压缩)算法,局部是通过复制算法,不会产生内存碎片。
优点:
底层原理:
先用top命令查看cpu占比最高的,然后用jps来查看是那一个后台进程。
jps -l
然后使用
ps - mp 进程ID -o THREAD,tid,time
printf "%x\n" 线程ID
得到转换后的16进制格式 小写。
然后使用
jstack 进程ID | grep 线程id -A60
java虚拟机对class文件采用的是按需加载,只有当需要用到的时候才会加载到内存生成class对象,采用的是双亲委派机制,把请求交给父类处理。是一种任务委派模式。
优点:
Spring是一个轻量级的Ioc和AOP容器框架,简化开发,只需要关注业务需求。
AOP就是面向切面,用于将和业务无关的逻辑代码抽取并封装成一个可用的模块,减少重复代码,降低模块之间的耦合度,提高系统的可维护性。
AOP实现的关键在于代理模式,AOP代理主要分为静态代理和动态代理,静态代理为AspectJ;动态代理为Spring AOP。
AspectJ是静态代理的增强,就是AOP框架会在编译阶段生成AOP代理类,将AspectJ织入进Java字节码中,运行的时候就是增强后的AOP对象。
Spring AOP使用的是动态代理,所谓动态代理就是AOP不会修改字节码,而是每次运行中在内存临时为方法生成一个AOP对象,这个AOP对象包含了目标对象的全部方法,并且在特定的切点做了增强处理,并回调原始对象方法。
动态代理分为JDK和CGLib代理:
jdk动态代理是只提供接口的代理,不支持类的代理。核心InvocationHandler接口和Proxy类,InvocationHandler通过invoke()方法反射来调用目标代码,动态的将和逻辑和业务编织在一起;然后利用InvocationHandler动态创建一个符合某一接口的实例生成目标类的代理对象。
如果代理类没有实现InvocationHandler接口,Spring AOP会选择CGLib代理,可以运行时动态的生成一个指定类的子类对象,并覆盖其中方法添加增强代码,CGLib是通过继承来做动态代理,如果某个类被标记为final,则无法使用CGLib做动态代理。
IOC就是控制反转,就是对象创建时机由容器来控制,好处就是不需要主动new对象,spring会自动生产,使用java的反射机制,根据配置文件去创建对象。
有三种注入方式:1.构造器注入,2.setter注入,3.注解注入。
BeanFactory和ApplicationContext都是Spring的核心接口,都可以当做Spring的容器。 BeanFactroy是使用的延时加载来注入bean,只有当使用某个bean时候,才会进行加载,而ApplicationContext是在容器启动的时候就一次性创建所有Bean。
首先说一下Servlet的生命周期:实例化,初始init,接收请求service,销毁destroy;
不是,只能自行保证线程安全。
采用ThreadLocal进行处理,解决线程安全问题。
(1)工厂模式:BeanFactory就是简单工厂模式的体现,用来创建对象的实例;
(2)单例模式:Bean默认为单例模式。
(3)代理模式:Spring的AOP功能用到了JDK的动态代理和CGLIB字节码生成技术;
(4)模板方法:用来解决代码重复的问题。比如. RestTemplate, JmsTemplate, JpaTemplate。
(5)观察者模式:定义对象键一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都会得到通知被制动更新,如Spring中listener的实现–ApplicationListener。
① PROPAGATION_REQUIRED:如果当前没有事务,就创建一个新事务,如果当前存在事务,就加入该事务,该设置是最常用的设置。
② PROPAGATION_SUPPORTS:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就以非事务执行。‘
③ PROPAGATION_MANDATORY:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就抛出异常。
④ PROPAGATION_REQUIRES_NEW:创建新事务,无论当前存不存在事务,都创建新事务。
⑤ PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
⑥ PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。
⑦ PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则按REQUIRED属性执行。
① ISOLATION_DEFAULT:这是个 PlatfromTransactionManager 默认的隔离级别,使用数据库默认的事务隔离级别。
② ISOLATION_READ_UNCOMMITTED:读未提交,允许另外一个事务可以看到这个事务未提交的数据。
③ ISOLATION_READ_COMMITTED:读已提交,保证一个事务修改的数据提交后才能被另一事务读取,而且能看到该事务对已有记录的更新。
④ ISOLATION_REPEATABLE_READ:可重复读,保证一个事务修改的数据提交后才能被另一事务读取,但是不能看到该事务对已有记录的更新。
⑤ ISOLATION_SERIALIZABLE:一个事务在执行的过程中完全看不到其他事务对数据库所做的更新。
Spring对Bean实例创建采用的是单例注册表进行实现的,这个注册表缓存是ConcurrentHashMap对象。
使用@Transactional注解,声明式注解是建立在AOP上,对方法的前后进行拦截,然后根据目标方法来判断是提交还是回滚事务。
1、编程式事务,在代码中硬编码。(不推荐使用)
2、声明式事务,在配置文件中配置(推荐使用)
声明式事务又分为两种:
a、基于XML的声明式事务
b、基于注解的声明式事务
Spring Beans是Spring应用的核心对象,由IOC容器实例化、组装、管理。这些对象通过容器中配置的元数据进行创建。
用来装配Beans之间的依赖注入
单例,延迟加载,松耦合。
关注点是应用中一个模块的行为,一个关注点可能会被定义成一个我们想实现的一个功能。
横切关注点是一个关注点,此关注点是整个应用都会使用的功能,并影响整个应用,比如日志,安全和数据传输,几乎应用的每个模块都需要的功能。因此这些都属于横切关注点。
切入点是一个或一组连接点,通知将在这些位置执行。可以通过表达式或匹配的方式指明切入点。
1>.InnoDB支持事物,而MyISAM不支持事物
2>.InnoDB支持行级锁,而MyISAM支持表级锁
3>.InnoDB支持MVCC, 而MyISAM不支持
4>.InnoDB支持外键,而MyISAM不支持
5>.InnoDB不支持全文索引,而MyISAM支持。
插入缓冲(insert buffer)、二次写(double write)、自适应哈希索引(ahi)、预读(read ahead)。
myisam更快,因为myisam内部维护了一个计数器,可以直接调取。
错误日志:记录出错信息,也记录一些警告信息或者正确的信息。
查询日志:记录所有对数据库请求的信息,不论这些请求是否得到了正确的执行。
慢查询日志:设置一个阈值,将运行时间超过该值的所有SQL语句都记录到慢查询的日志文件中。
二进制日志:记录对数据库执行更改的所有操作。
中继日志:中继日志也是二进制日志,用来给slave 库恢复
事务日志:重做日志redo和回滚日志undo
select_type
表示查询中每个select子句的类型
type
表示MySQL在表中找到所需行的方式,又称“访问类型”
possible_keys
指出MySQL能使用哪个索引在表中找到行,查询涉及到的字段上若存在索引,则该索引将被列出,但不一定被查询使用
key
显示MySQL在查询中实际使用的索引,若没有使用索引,显示为NULL
key_len
表示索引中使用的字节数,可通过该列计算查询中使用的索引的长度
ref
表示上述表的连接匹配条件,即哪些列或常量被用于查找索引列上的值
Extra
包含不适合在其他列中显示但十分重要的额外信息
InnoDB是基于索引来完成行锁
例: select * from tab_with_index where id = 1 for update;
for update 可以根据条件来完成行锁锁定,并且 id 是有索引键的列,
如果 id 不是索引键那么InnoDB将完成表锁。
索引是一种数据结构,方便我们快速的进行数据查找。
InnoDB使用的是B+树索引。
例如:
select * from t where name=‘lisi’;
是如何执行的呢?
如粉红色路径,需要扫码两遍索引树:
(1)先通过普通索引定位到主键值id=5;
(2)再通过聚集索引定位到行记录;
这就是所谓的回表查询,先定位主键值,再定位行记录,它的性能较扫一遍索引树更低。
①聚集索引(聚簇索引):以 InnoDB 作为存储引擎的表,表中的数据都会有一个主键,即使你不创建主键,系统也会帮你创建一个隐式的主键。
这是因为 InnoDB 是把数据存放在 B+ 树中的,而 B+ 树的键值就是主键,在 B+ 树的叶子节点中,存储了表中所有的数据。
这种以主键作为 B+ 树索引的键值而构建的 B+ 树索引,我们称之为聚集索引。
②非聚集索引(非聚簇索引):以主键以外的列值作为键值构建的 B+ 树索引,我们称之为非聚集索引。
非聚集索引与聚集索引的区别在于非聚集索引的叶子节点不存储表中的数据,而是存储该列对应的主键,想要查找数据我们还需要根据主键再去聚集索引中进行查找,这个再根据聚集索引查找数据的过程,我们称为回表。
明白了聚集索引和非聚集索引的定义,我们应该明白这样一句话:数据即索引,索引即数据。
Hash树底层就是Hash表,进行查找时,调用一次hash函数就可以获得到响应的键值,之后进行回表查询获得实际数据。
B+树等底层是多路平衡查找树,对每一次查询都是从根节点出发,找到叶子节点后获得所查的键值,然后根据查询判断是否要回表操作。
B树,叶子节点和非叶子节点都存储数据。
B+树,只有在叶子节点上存储数据。
B树是有序数组+平衡多叉树。
B+树是有序数组链表+平衡多叉树。
B+树只需要便利叶子节点就可以对整棵树进行遍历,而B树需要查询非叶子节点效率不高。
在B+树的索引种,叶子节点除了可能存储了当前的K值,也可能存储了当前K值和整行数据,这就是聚集索引和非聚集索引。
在InnoDB中,只有主键是聚集索引,如果没有主键,则挑选一个唯一键来建立聚集索引,如果没有唯一键,则隐式的生成一个键来建立聚集索引。
当使用聚集索引时候,对应的叶子节点可以获取整行数据,所以不用再次回表查询。
不一定,如果查询的字段如果全部命中索引,就没有必要进行回表查询。
字段的使用频率。
联合索引的话,还需要考虑顺序。
不经常计算的字段。
Mysql可以用多个字段同时建立一个索引,叫做联合索引。
在联合索引中如果命中索引,则需要按照建立索引的字段顺序挨个使用,否则无法命中索引。
MySQL使用索引时需要索引有序,假设现在建立了"name,age,school"的联合索引,那么索引的排序为: 先按照name排序,如果name相同,则按照age排序,如果age的值也相等,则按照school进行排序.
可以看到a的值是有顺序的,1,1,2,2,3,3,而b的值是没有顺序的1,2,1,4,1,2。所以b = 2这种查询条件没有办法利用索引,因为联合索引首先是按a排序的,b是无序的。
同时我们还可以发现在a值相等的情况下,b值又是按顺序排列的,但是这种顺序是相对的。所以最左匹配原则遇上范围查询就会停止,剩下的字段都无法使用索引。例如a = 1 and b = 2 a,b字段都可以使用索引,因为在a值确定的情况下b是相对有序的,而a>1and b=2,a字段可以匹配上索引,但b值不可以,因为a的值是一个范围,在这个范围中b是无序的。
最左匹配原则: 最左优先,以最左边的为起点任何连续的索引都能匹配上。同时遇到范围查询(>、<、between、like)就会停止匹配。
参与数学运算或者函数。
在like左边使用了%。
当mysql分析全表扫描比使用索引快时。
当使用联合索引时,不符合最左前缀匹配原则。
varchar不加引号。
使用 not <> != 都会失效。
事务就是一系列的操作,要么全部成功,要么全部失败。需要符合ACID特性。
A=Atomicity
原子性:要么全部成功,要么全部失败。不可能只执行一部分操作。
C=Consistency
一致性:系统(数据库)总是从一个一致性的状态转移到另一个一致性的状态,不会存在中间状态。
I=Isolation
隔离性:通常来说一个事务在完全提交之前,对其他事务是不可见的。
D=Durability
持久性:一旦事务提交,那么就永远是这样子了,哪怕系统崩溃也不会影响到这个事务的结果。
脏读:A事务读取到了B事务未提交的内容,而B事务后面进行了回滚。
不可重复读:当设置A事务只能读取B事务已经提交的部分,会造成A事务内的两次查询结果不一样,因为B在此期间对事务进行了提交。
幻读:A事务读取了一个范围的内容,同时B事务在此期间插入了一条数据,造成”幻觉“。
MySQL的四种隔离级别如下:
这个隔离级别下,其他事务可以看到本事务没有提交的部分修改。因此会造成脏读的问题(读取到了其他事务未提交的部分,而之后该事务进行了回滚)。
这个级别的性能没有足够大的优势,但是又有很多的问题,因此很少使用。
其他事务只能读取到本事务已经提交的部分。这个隔离级别有不可重复读的问题,在同一个事务内的两次读取,拿到的结果竟然不一样,因为另外一个事务对数据进行了修改。
可重复读隔离级别解决了上面不可重复读的问题(看名字也知道),但是仍然有一个新问题,就是 幻读,当你读取id> 10 的数据行时,对涉及到的所有行加上了读锁,此时例外一个事务新插入了一条id=11的数据,因为是新插入的,所以不会触发上面的锁的排斥,那么进行本事务进行下一次的查询时会发现有一条id=11的数据,而上次的查询操作并没有获取到,再进行插入就会有主键冲突的问题。
这是最高的隔离级别,可以解决上面提到的所有问题,因为他强制将所以的操作串行执行,这会导致并发性能极速下降,因此也不是很常用。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
READ UNCOMMITTED | √ | √ | √ |
READ COMMITTED | √ | √ | |
REPEATABLE READ | √ | ||
SERIALIZABLE |
查看当前的事务隔离级别:
SELECT @@global.tx_isolation;
修改当前事务隔离级别:
set global transaction isolation level read committed;
InnoDB默认使用的是可重复读隔离级别。
从类别上来讲,有共享锁和排它锁。
共享锁,又叫读锁:当用户要进行数据的读取时候,对数据加上共享锁,共享锁可以有多个。
排它锁,又叫写锁:当用户进行数据写入时,对数据加上排它锁,排它锁只能有一个。
锁的粒度取决于具体的存储引擎,InnoDB实现了行级锁、页级锁、表级锁。
加锁的开销从大到小,并发能力从大大小。
推荐使用自增ID,不要使用UUID。
因为在InnoDB存储引擎中,主键索引是作为聚集索引存在的,也就是说,主键索引的B+树节点上存储了主键索引以及全部数据。如果是自增ID的话,只需要往后排列即可。如果是UUID,每个ID的大小不确定,会导致数据移动,产生垃圾碎片,从而造成性能下降。
图片来源于《高性能MySQL》: 其中默认后缀为使用自增ID,_uuid为使用UUID为主键的测试,测试了插入100w行和300w行的性能。
密码散列,用户身份证号等固定长度的字符串都应该使用char而不是varchar,这样可以节省空间提高检索效率。
char是一个定长字段,如果申请的长度为10的空间,无论实际存储多少内容,该字段都占用10个字符。
varchar是变长的,也就是说申请的只是最大长度,占用的空间为实际字符长度+1,最后一个字符存储使用了多少空间。
在检索效率上来讲,char > varchar
因此在使用中,如果确定某个字段的值的长度,可以使用char,否则应该尽量使用varchar。例如存储用户MD5加密后的密码,则应该使用char。
存储过程是一些预编译的SQL语句。1、更加直白的理解:存储过程可以说是一个记录集,它是由一些T-SQL语句组成的代码块,这些T-SQL语句代码像一个方法一样实现一些功能(对单表或多表的增删改查),然后再给这个代码块取一个名字,在用到这个功能的时候调用他就行了。2、存储过程是一个预编译的代码块,执行效率比较高,一个存储过程替代大量T_SQL语句 ,可以降低网络通信量,提高通信速率,可以一定程度上确保数据安全。
第一范式:每个列都不可再拆分。
第二范式:非主键列完全依赖主键,而不是主键的一部分。
第三范式:非主键列只依赖主键,不依赖其他非主键。
Redis本质是一直Key-Value类型的内存数据库。因为是纯内存操作所以性能非常好,而且还支持多种数据结构。另外Redis可以对存入的Key-Value设置expire时间。缺点是数据库容量受到物理内存的限制,不能做海量数据的高性能读写。
数据类型 | 使用场景 |
---|---|
String | 缓存,计数器,session |
Hash | 存储用户信息,【key,field,value】语法:Hset key field value |
List | 最新消息的排行,还可以利用list的push命令,将任务存在list集合中,同时使用pop命令,将任务从集合中取出。模拟消息队列。 |
Set | 无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。 |
Zset | Redis 有序集合和集合一样也是string类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。有序集合的成员是唯一的,但分数(score)却可以重复。 |
redis采用的是定期删除+惰性删除。
为什么不使用定时删除?
因为定时删除是使用一个定时器去监视key,过期则自动删除。虽然内存及时释放,但是十分消耗cpu资源。在高并发环境下,cpu应该将时间应用在处理请求上,而不是删除key。
定期删除+惰性删除是如何工作的呢?
定期删除:redis每隔100ms检查是否有过期的key,如果有则删除,需要说明的是,redis并不是一下检查所有key,而是随机抽取检查。
因此定期删除可能会导致很多key没有被检查到,这个时候惰性删除就派上用场。
惰性删除:在每一次获取某个key时,去检查key是否过期,如果过期则删除。
如果惰性删除也没有获取到某个key,这个时候采用淘汰机制
在redis.conf中有一行配置
maxmemory-policy volatile-lru
volatile-lru: 从设置过期时间的数据集中挑选最近最少使用的数据淘汰。
volatile-ttl: 从设置过期时间的数据集中挑选将要过期的数据淘汰。
volatile-random: 从设置过期时间的数据集中随机选择数据淘汰。
allkeys-lru: 从数据集中挑选最近最少使用的数据淘汰。
allkeys-random: 从数据集中任意选择数据淘汰。
no-eviction(驱逐): 禁止驱逐数据,新写入操作会报错。
redis默认使用的是no-eviction。
因为redis是单线程的,但是如果在多个命令并发中不是原子性的。
如果想实现原子性可以使用incr命令,或者redis事务,或者使用redis-lua的方式。
Redis 事务可以一次执行多个命令, 并且带有以下三个重要的保证:
一个事务从开始到执行会经历以下三个阶段:
以 MULTI 开始一个事务, 然后将多个命令入队到事务中, 最后由 EXEC 命令触发事务, 一并执行事务中的所有命令
命令 | 含义 |
---|---|
MULTI | 标记一个事务的开始 |
EXEC | 执行事务块中的所有命令 |
DISCARD | 取消事务,放弃执行事务块中的所有命令 |
WATCH | 监视一个或多个key,如果在事务执行前被其他命令改动,事务将被打断 |
UNWATCH | 取消WATCH命令对所有key的监视 |
RDB 在指定的时间间隔内将数据以快照的形式写入硬盘,恢复时候是直接将文件读取到内存中。
RDB是如果进行备份的?
redis会单独创建一个子进程来进行持久化,先将数据写入一个临时文件,等持久化完毕后再替换上次的文件,整个过程中主进程是不进行IO操作的。
RDB优点:效率非常高。节省磁盘空间。
RDB缺点: RDB都是快照文件,都是默认五分钟甚至更久的时间才会生成一次,这意味着你这次同步到下次同步这中间五分钟的数据都很可能全部丢失掉。AOF则最多丢一秒的数据,数据完整性上高下立判。
AOF 将redis执行中的所有写命令记录下来,只追加文件不改写文件,redis启动之初会读取该文件重新加载数据。
AOF优点:备份机制稳健,丢失概率低。可读的日志文件 可以处理误操作。
AOF缺点:更占用磁盘空间,恢复速度慢,每次读写同步的话性能会有压力。
主要是发生在并发写竞争。
1.使用乐观锁的方式进行解决;(watch机制配合事务锁)
2.排队的机制进行。将所有需要对同一个key的请求进行入队操作,然后用一个消费者线程从队头依次读出请求,并对相应的key进行操作。
这样对于同一个key的所有请求就都是顺序访问,正常逻辑下则不会有写失败的情况下产生 。从而最大化写逻辑的总体效率。
1.客户端角度,为保证每个客户端间正常有序与Redis进行通信,对连接进行池化,同时对客户端读写Redis操作采用内部锁synchronized。
2.服务器角度,利用setnx实现锁。
缓存穿透就是指缓存和数据库中都没有的数据,而用户不断发起请求,由于缓存中查不到数据,导致每次都需要去数据库查询,失去了缓存的意义。
比如发起一个id为”-1“的值或者一个特别大的不存在的值,这个时候用户很可能是攻击者,会导致数据库压力过大。
解决方法:
增加接口校验,比如id<=0直接拦截,用户权限校验。
可以从代码中解决:如果缓存中取不到且数据库中也没有,这个时候可以将key-value写为key-null,缓存有效时间设置短点,比如30秒。可以防止用户反复对同一个id暴力攻击。
布隆过滤器(BloomFilter)利用高效的数据结构和算法快速判断出你这个Key是否在数据库中存在,不存在你return就好了,存在你就去查了DB刷新KV再return。
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
解决方法:
缓存雪崩是指缓存中数据大批量的到达过期时间,而查询数据量巨大,引起数据库压力过大。
解决方案:
缓存数据的过期时间设置随机,防止同一时间大量数据过期。
如果缓存数据库是分布式部署,将热点数据均匀在不同Redis中。
设置热点数据永不过期。
完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。它的数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1);
使用多路I/O复用模型,非阻塞IO;
采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
哨兵模式是一种特殊的模式,首先Redis提供了哨兵的命令,哨兵是一个独立的进程,作为进程,可以独立运行。其原理是哨兵通过发送命令,等待Redis服务器响应,从而监控运行多个Redis实例。
监控(Monitoring): 哨兵(sentinel) 会不断地检查你的Master和Slave是否运作正常。
提醒(Notification):当被监控的某个 Redis出现问题时, 哨兵(sentinel) 可以通过 API 向管理员或者其他应用程序发送通知。
自动故障迁移(Automatic failover): 当一个Master不能正常工作时,哨兵(sentinel) 会开始一次自动故障迁移操作,它会将失效Master的其中一个Slave升级为新的Master, 并让失效Master的其他Slave改为复制新的Master; 当客户端试图连接失效的Master时,集群也会向客户端返回新Master的地址。
采用AMQP高级消息队列协议的一种消息队列技术,最大的特点就是消费方并不需要确保提供方存在,实现了服务之间的高度解耦。
拥有持久化机制。
实现生产者和消费者之间解耦。
利用消息队列可以使同步访问变为串行访问达到一定量的限流。
服务之间异步通信。
顺序消费。
定时任务。
请求削峰。
发送方确认模式
将信道设置为confirm模式,则所有信道发布的消息都会被指派一个唯一的ID。
一旦消费被投递到目标队列后,或者消息被写入磁盘后,信道会发送一个确认信息给生产者。
如果rabbitmq发生内部错误导致消息丢失,会发送一条nack消息。
发送方的确认模式是异步的,生产者在等待的确认的同时,可以继续发送消息。当消息nack后会触发回调方法来处理确认消息。
接收方确认机制
消费者接受每一条信息后都必须进行确认。只有消费者确认了消息,rabbitmq才可以安全的将消息从队列中删除。
消费者在订阅队列时,可以指定autoAck
参数:
当autoAck
设置为true时,rabbitmq会自动把发送出去的消息置为确认,然后从内存(或磁盘)中删除,而不管是否真的消费到了消息。
当autoAck
设置为false时,rabbitmq会等待消费者显式的回复确认信号后才会将内存(或磁盘)数据删除。
在消息生产时,rabbitmq内部针对每条生产者发送的消息生成一个inner-msg-id,作为去重的依据(消息投递失败并重传),避免重复消息进入队列。
在消息消费时,要求消息体重必须有一个bizId(对同一业务全局唯一)作为去重和幂等的依据,避免重复消费。
举例:
比如拿到这条消息做数据库的insert操作,给这个消息做一个唯一主键,就算出现了重复消费的情况,就会导致主键冲突,避免脏数据的产生。
如果要存入redis的话,不需要任何操作,因为无论set多少次结果都一样,set是幂等操作。
如果还不行的话就在消费的时候先存入redis,下次重复消费的时候先查询一下redis里有没有,如果有的话则不消费。
由于TCP连接的创建和销毁开销较大,且并发数受系统资源限制,会造成性能瓶颈。rabbitmq使用的是信道的方式来传输数据。信道是建立在真是的TCP连接内的虚拟连接,且每条TCP连接上的信道是没有数量限制的。
消息提供方–>路由–>一个或者多个队列消息发布的交换机上,消息将拥有一个路由键(routing key),在消息创建时设定。通过队列路由器,可以把队列绑定到交换机上。消息到达交换机后,rabbitmq会将消息的路由键与队列的路由键进行匹配(针对不同的交换机有不同的路由规则)。
交换机分为以下三种:
fanout:如果交换机收到消息,将会广播到所有绑定的队列上。
direct:如果路由键完全匹配,消息就投递到响应的队列上。
topic:可以使来自不同的源头的消息能够到达同一队列,使用topic交换器时,可以使用通配符。
消息持久化,当然前提是队列必须持久化。
rabbitmq确保持久性消息能从服务器重启中恢复的方式是,将它们写入磁盘的一个持久化日志,当发布一条持久性消息到持久交换机上时,rabbitmq会把消息提交到日志文件后才发送响应。
一旦消费者从持久化队列中消费了一条持久化的消息,rabbitmq会在持久化日志中将这条消息标记为等待垃圾收集。如果持久化消息在被消费之前rabbitmq重启,那么rabbitmq会自动重建交换机和队列以及绑定,并重新发布持久化日志文件中的消息到合适的队列。
Dubbo是阿里巴巴开源的基于Java的高性能RPC分布式服务框架。
因为是阿里开源项目,国内很多互联网公司都在使用,已经经过了很多线上考验。
内部使用Netty、Zookeeper,保证了高性能高可用性。
使用dubbo可以将核心业务抽取出来,作为独立的服务,用于提高业务复用灵活扩展。
不需要,如果硬要用 Web 容器,只会增加复杂性,也浪费资源。
Dubbo的服务容器只是一个简单的Main方法,并加载一个简单的Spring容器,用于暴露服务。
节点 | 角色说明 |
---|---|
Provider | 暴露服务的服务提供方 |
Consumer | 调用远程服务的服务消费方 |
Registry | 服务注册与发现的注册中心 |
Monitor | 统计服务的调用次数和调用时间的监控中心 |
Container | 服务运行容器 |
配置 | 配置说明 | 解释 |
---|---|---|
服务配置 | 用于暴露一个服务,定义服务的元信息,一个服务可以用多个协议暴露,一个服务也可以注册到多个注册中心 | |
引用配置 | 用于创建一个远程服务代理,一个引用可以指向多个注册中心 | |
协议配置 | 用于配置提供服务的协议信息,协议由提供方指定,消费方被动接受 | |
应用配置 | 用于配置当前应用信息,不管该应用是提供者还是消费者 | |
模块配置 | 用于配置当前模块信息,可选 | |
注册中心配置 | 用于配置连接注册中心相关信息 | |
监控中心配置 | 用于配置连接监控中心相关信息,可选 | |
提供方配置 | 当 ProtocolConfig 和 ServiceConfig 某属性没有配置时,采用此缺省值,可选 | |
消费方配置 | 当 ReferenceConfig 某属性没有配置时,采用此缺省值,可选 | |
方法配置 | 用于 ServiceConfig 和 ReferenceConfig 指定方法级的配置信息 | |
参数配置 | 用于指定方法参数配置 |
不同粒度配置的覆盖关系
以 timeout 为例,下图显示了配置的查找顺序,其它 retries, loadbalance, actives 等类似:
其中,服务提供方配置,通过 URL 经由注册中心传递给消费方。
(建议由服务提供方设置超时,因为一个方法需要执行多长时间,服务提供方更清楚,如果一个消费方同时引用多个服务,就不需要关心每个服务的超时设置)。
理论上 ReferenceConfig 中除了interface
这一项,其他所有配置项都可以缺省不配置,框架会自动使用ConsumerConfig,ServiceConfig, ProviderConfig等提供的缺省配置。
在 Provider 上可以配置的 Consumer 端的属性有哪些?
1)timeout:方法调用超时
2)retries:失败重试次数,默认重试 2 次
3)loadbalance:负载均衡算法,默认随机
4)actives 消费者端,最大并发调用限制
集群容错方案 | 说明 |
---|---|
Failover Cluster | 失败自动切换,自动重试其他服务器(默认) |
Failfast Cluster | 快速失败,立即报错,只发起一次调用 |
Failsafe Cluster | 失败安全,出现异常时,直接忽略 |
Failback Cluster | 失败自动恢复,记录失败请求,定时重发 |
ForKing Cluster | 并行调用多个服务器,只要一个成功即返回 |
Broadcast Cluster | 广播逐个调用所有提供者,任意一个报错则报错 |
负载均衡策略 | 说明 |
---|---|
Random LoadBalance | 随机,按权重设置随机概率(默认) |
RoundRobin LoadBalance | 轮训,按公约后的权重设置轮询比率 |
LeastActive LoadBalance | 最少活跃调用数,相同或约束的随机 |
ConsistentHash LoadBalance | 一致性Hash,相同参数的请求总是发送到同一提供者 |
可以使用group属性来分组,服务提供方和消费方都指定同一个group即可。
默认是同步等待结果阻塞的,支持异步调用。
Dubbo是基于NIO的非阻塞实现并行调用,客户端不需要启动多线程即可完成并行调用多个远程服务,相对多线程开销较小,异步调用会返回一个Future对象。
Dubbo会在Spring实例化Bean之后,在刷新容器最后一步发布ContextRefreshEvent事件,通知实现了ApplicationListener的ServiceBean类进行回调onApplicationEvent事件方法,Dubbo会在这个方法中调用ServiceBean父类ServiceConfig的export方法,从而实现了服务发布。
欢迎关注我的公众号:Java菜鸟程序员
希望可以一起探讨交流,一起学习!