Java面试题汇总(持续更新)

Java面试题汇总(欢迎各位补充)

String 相关面试题目

问:String、StringBuffer、StringBuilder 的区别是什么?

答:String 是字符串常量,StringBuffer 和 StringBuilder 都是字符串变量,后两者的字符内容可变,而前者创建后内容不可变;StringBuffer 是线程安全的,而 StringBuilder 是非线程安全的,线程安全会带来额外的系统开销,所以 StringBuilder 的效率比 StringBuffer 高;String 的每次修改操作都是在内存中重新 new 一个对象出来,而 StringBuffer、StringBuilder 则不用,且提供了一定的缓存功能,默认 16 个字节数组的大小,超过默认的数组长度时扩容为原来字节数组的长度 * 2 + 2,所以使用 StringBuffer 和 StringBuilder 时可以适当考虑下初始化大小,以便通过减少扩容次数来提高代码的高效性。

问:String 为什么是不可变的?

答:String 不可变是因为在 JDK 中 String 类被声明为一个 final 类,且类内部的 value 字节数组也是 final 的,只有当字符串是不可变时字符串池才有可能实现,字符串池的实现可以在运行时节约很多 heap 空间,因为不同的字符串变量都指向池中的同一个字符串;如果字符串是可变的则会引起很严重的安全问题,譬如数据库的用户名密码都是以字符串的形式传入来获得数据库的连接,或者在 socket 编程中主机名和端口都是以字符串的形式传入,因为字符串是不可变的,所以它的值是不可改变的,否则黑客们可以钻到空子改变字符串指向的对象的值造成安全漏洞;因为字符串是不可变的,所以是多线程安全的,同一个字符串实例可以被多个线程共享,这样便不用因为线程安全问题而使用同步,字符串自己便是线程安全的;因为字符串是不可变的所以在它创建的时候 hashcode 就被缓存了,不变性也保证了 hash 码的唯一性,不需要重新计算,这就使得字符串很适合作为 Map 的键,字符串的处理速度要快过其它的键对象,这就是 HashMap 中的键往往都使用字符串的原因。

问:说说 String str = “hello world”; 和 String str = new String(“hello world”); 的区别?

答:在 java 的 class 文件中有专门的部分用来存储编译期间生成的字面常量和符号引用,这部分叫做 class 文件常量池,在运行期间对应着方法区的运行时常量池,所以 String str = “hello world”; 在编译期间生成了 字面常量和符号引用,运行期间字面常量 “hello world” 被存储在运行时常量池(只保存了一份),而通过 new 关键字来生成对象是在堆区进行的,堆区进行对象生成的过程是不会去检测该对象是否已经存在的,所以通过 new 来创建的一定是不同的对象,即使字符串的内容是相同的。例子如下

	public static void main(String[] args) {
		String str1 = "hello";
		String str2 = "hello";
		String str3 = new String("hello");
		
		System.out.println("str1 =" +  System.identityHashCode(str1));
		System.out.println("str2 =" +  System.identityHashCode(str2));
		System.out.println("str3 =" +  System.identityHashCode(str3));
	}

输出日志如下:

str1 =2018699554
str2 =2018699554
str3 =1311053135

问:语句 String str = new String(“abc”); 一共创建了多少个对象?

答:这个问题其实有歧义,但是很多公司还特么爱在笔试题里面考察,非要是遇到了就答两个吧(一个是 “xyz”,一个是指向 “xyz” 的引用对象 str);之所以说有歧义是因为该语句在运行期间只创建了一个对象(堆上的 “abc” 对象),而在类加载过程中在运行时常量池中先创建了一个 “abc” 对象,运行期和类加载又是有区别的,所以这个题目的问法是有些不严谨的。因此这个问题如果换成 String str = new String(“abc”); 涉及到几个 String 对象,则答案就是 2 个了。关于这道题可以参考文章《请别再拿“String s = new String(“xyz”); 创建了多少个 String 实例”来面试了吧》,链接:http://rednaxelafx.iteye.com/blog/774673/

集合相关

HashMap相关

问:HashMap 中的 key 如果是 Object 则需要实现哪些方法?

答:hashCode 方法和 equals 方法。
因为 hashCode 方法用来计算 Entry 在数组中的 index 索引位置,equals 方法用来比较数组指定 index 索引位置上链表的节点 Entry 元素是否相等。否则由于 hashCode 方法实现不恰当会导致严重的 hash 碰撞,从而使 HashMap 会退化成链表结构而影响性能。

问:为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键,即为什么使用它们可以减少哈希碰撞?

答:因为 String、Integer 等包装类是 final 类型的,具有不可变性,而且已经重写了 equals() 和 hashCode() 方法。不可变性保证了计算 hashCode() 后键值的唯一性和缓存特性,不会出现放入和获取时哈希码不同的情况且读取哈希值的高效性,此外官方实现的 equals() 和 hashCode() 都是严格遵守相关规范的,不会出现错误。

问:下面程序的输出结果是什么?
import java.util.HashMap;
import java.util.Map;

class Item {
		public String name;
		public int age;
		
		@Override
		public int hashCode() {
			return this.name.hashCode() + this.age;
		}
		
		public Item(String name, int age) {
			this.name = name;
			this.age = age;
		}
	} 
	
	
	public class Demo {
		public static void main(String[] args) {
			Item item1 = new Item("java", 19);
			Item item2 = new Item("Android", 12);
			Map<Item, Item> map = new HashMap<Item, Item>();
			map.put(item1, item1);
			map.put(item2, item2);
			
			item2.name = "Kotlin";
			Item value = map.get(item2);
			System.out.println("value = " + value);
		}
	}

答:输出结果为 value=null。

因为 key 更新后 hashCode 也更新了,而 HashMap 里面的对象是我们原来哈希值的对象,在 get 时由于哈希值已经变了,原来的对象不会被索引到了,所以结果为 null,因此当把对象放到 HashMap 后就不要尝试对 key 进行修改操作,谨防出现哈希值变化或者 equals 比较不等的情况导致无法索引。

问:简单说说 HashMap 的底层原理?

答:当我们往 HashMap 中 put 元素时,先根据 key 的 hash 值得到这个 Entry 元素在数组中的位置(即下标),然后把这个 Entry 元素放到对应的位置中,如果这个 Entry 元素所在的位子上已经存放有其他元素就在同一个位子上的 Entry 元素以链表的形式存放,新加入的放在链头,从 HashMap 中 get Entry 元素时先计算 key 的 hashcode,找到数组中对应位置的某一 Entry 元素,然后通过 key 的 equals 方法在对应位置的链表中找到需要的 Entry 元素,所以 HashMap 的数据结构是数组和链表的结合,此外 HashMap 中 key 和 value 都允许为 null,key 为 null 的键值对永远都放在以 table[0] 为头结点的链表中。

之所以 HashMap 这么设计的实质是由于数组存储区间是连续的,占用内存严重,故空间复杂度大,但二分查找时间复杂度小(O(1)),所以寻址容易而插入和删除困难;而链表存储区间离散,占用内存比较宽松,故空间复杂度小,但时间复杂度大(O(N)),所以寻址困难而插入和删除容易;所以就产生了一种新的数据结构叫做哈希表,哈希表既满足数据的查找方便,同时不占用太多的内容空间,使用也十分方便,哈希表有多种不同的实现方法,HashMap 采用的是链表的数组实现方式。

特别说明,对于 JDK 1.8 开始 HashMap 实现原理变成了数组+链表+红黑树的结构,数组链表部分基本不变,红黑树是为了解决哈希碰撞后链表索引效率的问题,所以在 JDK 1.8 中当链表的节点大于 8 个时就会将链表变为红黑树。区别是 JDK 1.8 以前碰撞节点会在链表头部插入,而 JDK 1.8 开始碰撞节点会在链表尾部插入,对于扩容操作后的节点转移 JDK 1.8 以前转移前后链表顺序会倒置,而 JDK 1.8 中依然保持原序。

问:HashMap 默认的初始化长度是多少?为什么默认长度和扩容后的长度都必须是 2 的幂?

答:在 JDK 中默认长度是 16(在 Android SDK 中的 HashMap 默认长度为 4),并且默认长度和扩容后的长度都必须是 2 的幂。因为我们可以先看下 HashMap 的 put 方法核心,如下

public V put(K key, V value) {
    ......
    int hash = hash(key);
    // 通过key的hash值和当前动态数组的长度求出当前key的Entry在数组中的下标
    int i = indexFor(hash, table.length);
    .......
}

static int indexFor(int h, int length) {
    return h & (length -1);
}

可以看到获取数组索引的计算方式为 key 的 hash 值按位与运算数组长度减一,为了说明长度尽量是 2 的幂的作用我们假设执行了 put(“android”, 123); 语句且 “android” 的 hash 值为 234567,二进制为 111001010001000111,然后由于 HashMap 默认长度为 16,所以减一后的二进制为 1111,接着两数做按位与操作二进制结果为 111,即十进制的 7,所以 index 为 7,可以看出这种按位操作比直接取模效率要高。

如果假设 HashMap 默认长度不是 2 的幂,譬如数组长度为 6,减一的二进制为 101,与 111001010001000111 按位与操作二进制 101,此时假设我们通过 put 再放一个 key-value 进来,其 hash 为 111001010001000101,其与 101 按位与操作后的二进制也为 101,很容易发生哈希碰撞,这就不符合 index 的均匀分布了。

通过上面两个假设例子可以看出 HashMap 的长度为 2 的幂时减一的值的二进制位数一定全为 1,这样数组下标 index 的值完全取决于 key 的 hash 值的后几位,因此只要存入 HashMap 的 Entry 的 key 的 hashCode 值分布均匀,HashMap 中数组 Entry 元素的分部也就尽可能是均匀的(也就避免了 hash 碰撞带来的性能问题),所以当长度为 2 的幂时不同的 hash 值发生碰撞的概率比较小,这样就会使得数据在 table 数组中分布较均匀,查询速度也较快。不过即使负载因子和 hash 算法设计的再合理也免不了哈希冲突碰撞的情况,一旦出现过多就会影响 HashMap 的性能,所以在 JDK 1.8 中官方对数据结构引入了红黑树,当链表长度太长(默认超过 8)时链表就转为了红黑树,而红黑树的增删改查都比较高效,从而解决了哈希碰撞带来的性能问题。

问:简单说说你对 HashMap 构造方法中 initialCapacity(初始容量)、loadFactor(加载因子)的理解?

答:这两个参数对于 HashMap 来说很重要,直接从一定程度决定了 HashMap 的性能问题。

initialCapacity 初始容量代表了哈希表中桶的初始数量,即 Entry< K,V>[] table 数组的初始长度,不过特别注意,table 数组的长度虽然依赖 initialCapacity,但是每次都会通过 roundUpToPowerOf2(initialCapacity) 方法来保证为 2 的幂次。

loadFactor 加载因子是哈希表在其容量自动增加之前可以达到多满的一种饱和度百分比,其衡量了一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。散列当前饱和度的计算为当前 HashMap 中 Entry 的存储个数除以当前 table 数组桶长度,因此当哈希表中 Entry 的数量超过了 loadFactor 加载因子乘以当前 table 数组桶长度时就会触发扩容操作。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大则对空间的利用更充分,从而导致查找效率的降低,如果负载因子太小则散列表的数据将过于稀疏,从而对空间造成浪费。系统默认负载因子为 0.75,一般情况下无需修改。

因此如果哈希桶数组很大则较差的 Hash 算法分部也会比较分散,如果哈希桶数组很小则即使好的 Hash 算法也会出现较多碰撞,所以就需要权衡好的 Hash 算法和扩容机制,也就是上面两个参数的作用。

问:简单说说 JDK1.7 中 HashMap 什么情况下会发生扩容?如何扩容?

答:HashMap 中默认的负载因子为 0.75,默认情况下第一次扩容阀值是 12(16 * 0.75),故第一次存储第 13 个键值对时就会触发扩容机制变为原来数组长度的二倍,以后每次扩容都类似计算机制;这也就是为什么 HashMap 在数组大小不变的情况下存放键值对越多查找的效率就会变低(因为 hash 碰撞会使数组变链表),而通过扩容就可以一定程度的平衡查找效率(尽量散列数组化)的原因所在。

具体的扩容方式对于 JDK 1.8 前后的实现是有一点区别的,不过大体思路不变(感兴趣可以先参阅 Java HashMap 基础面试常见问题 和 Java HashMap 实现概况及容量相关面试问题 了解基础知识),下面给出 JDK 1.7 的具体扩容流程:

void transfer(Entry[] newTable) {  
    Entry[] src = table;                   //src引用了旧的Entry数组  
    int newCapacity = newTable.length;  
    for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组  
        Entry<K, V> e = src[j];             //取得旧Entry数组的每个元素  
        if (e != null) {  
            src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)  
            do {  
                Entry<K, V> next = e.next;  
                int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置  
                e.next = newTable[i]; //标记[1]  
                newTable[i] = e;      //将元素放在数组上  
                e = next;             //访问下一个Entry链上的元素  
            } while (e != null);  
        }  
    }  
} 

可以看到,整个扩容过程就是一个取出数组元素(实际数组索引位置上的每个元素是每个独立单向链表的头部,也就是发生 Hash 冲突后最后放入的冲突元素)然后遍历以该元素为头的单向链表元素,依据每个被遍历元素的 hash 值计算其在新数组中的下标然后进行交换(即原来 hash 冲突的单向链表尾部变成了扩容后单向链表的头部)。下面给出图解流程:

问:简单说说 HashMap 和 LinkedHashMap 的区别?

答:HashMap 可以允许一条键为 Null 的键值对,允许多条值为 Null 的键值对,其并发不安全,如果想并发安全操作可以使用 Collections.synchronizedMap() 方法或 ConcurrentHashMap 来代替。

LinkedHashMap 是 HashMap 的一个子类,其特殊实现的仅仅是保存了记录的插入顺序,所以在 Iterator 迭代器遍历 LinkedHashMap 时先得到的键值对是先插入的(也可以在构造时用带参数构造方法来改变顺序为按照使用进行排序),由于其存储沿用了 HashMap 结构外还多了一个双向顺序链表,所以在一般场景下遍历时会比 HashMap 慢,此外具备 HashMap 的所有特性和缺点。

所以一般情况下,我们用的最多的是 HashMap,如果需要按照插入或者读取顺序来排列时就使用 LinkedHashMap。

问:简单说说什么是 LRU?

答:这算是一道纯概念题。LRU 是一种流行的替换算法,它的全称是 Least Recently Used,最近最少使用,常常在缓存设计的场景中充当一种策略,它的核心思路是最近刚被使用的很快再次被用的可能性最高,而最久没被访问的很快再次被用的可能性最低,所以被优先清理

问:请使用 Java 集合实现一个简约优雅的 LRU 容器?

答:这个题思路其实很多,咋一眼看起来是一道很难的题目,其实静下来你会发现想考察的其实就是 LRU 的原理和 LinkedHashMap 容器知识,当然,你要是厉害不依赖 LinkedHashMap 自己纯手写撸一个也不介意。

由于 LinkedHashMap 天生支持插入顺序或者访问顺序的 key-value 对,而 LRU 算法的核心恰巧用到它的访问顺序特性,即对一个键执行 get、put 操作后其对应的键值对会移到链表末尾,所以最末尾的是最近访问的,最开始的是最久没被访问的。恰巧我们之前推送 LinkedHashMap 原理相关题目时说过 LinkedHashMap 有一个 boolean 类型的 accessOrder 参数,当该参数为 true 时则按照元素最后访问时间在双向链表中排序,为 false 则按照插入顺序排序,默认为 false,所以这里需要的操作就是 accessOrder 为 true 的情况。

所以基于 LinkedHashMap 的特性实现的 LRU 容器如下:

public class LRU<K, V> extends LinkedHashMap<K, V> {
    private int maxEntries;
    
    public LRUI(int maxEntries) {
        super(16, 0.75f, true);
        this.maxEntries = maxEntries;
    }
    
    @override
    protected boolean removeEldestEntry(entry<K, V> eldest) {
        return size() > maxEntries;
    }
}

HashSet相关

问:简单谈谈你对 HashSet 原理的认识?

答:HashSet 在存元素时会调用对象的 hashCode 方法计算出存储索引位置,如果其索引位置已经存在元素(哈希碰撞)则和该索引位置上所有的元素进行 equals 比较,如果该位置没有其他元素或者比较的结果都为 false 就存进去,否则就不存。所以可以看见元素是按照哈希值来找位置的,故而无序且可以保证无重复元素,因此我们在往 HashSet 集合中存储元素时,元素对象应该正确重写 Object 类的 hashCode 和 equals 方法,否则会出现不可预知的错误。

问:说说 HashSet 与 HashMap 的区别?

答:从实质上说 HashSet 的实现实质就是一个 Map 对象的包装,只是 Map 的 value 为 Object 固定对象,Set 只利用了 Map 的 key 而已。具体区别来说如下:

  1. HashMap 实现了 Map 接口,而 HashSet 实现了 Set 接口。
  2. HashMap 储存键值对,而 HashSet 仅仅存储对象。
  3. HashMap 使用 put 方法将元素放入 Map 中,而 HashSet 使用 add 方法将元素放入 Set 中。
  4. HashMap 中使用键对象来计算 hashcode 值,而 HashSet 使用成员对象来计算 hashcode 值。
问:谈谈你理解的 LinkedList 工作原理和实现?

答:LinkedList 是以双向链表实现,链表无容量限制(但是双向链表本身需要消耗额外的链表指针空间来操作),其内部主要成员为 first 和 last 两个 Node 节点,在每次修改列表时用来指引当前双向链表的首尾部位,所以 LinkedList 不仅仅实现了 List 接口,还实现了 Deque 双端队列接口(该接口是 Queue 队列的子接口),故 LinkedList 自动具备双端队列的特性,当我们使用下标方式调用列表的 get(index)、set(index, e) 方法时需要遍历链表将指针移动到位进行访问(会判断 index 是否大于链表长度的一半决定是首部遍历还是尾部遍历,访问的复杂度为 O(N/2)),无法像 ArrayList 那样进行随机访问。(如果i>数组大小的一半,会从末尾移起),只有在链表两头的操作(譬如 add()、addFirst()、removeLast() 或用在 iterator() 上的 remove() 操作)才不需要进行遍历寻找定位。

问:简单谈谈 ArrayList、LinkedList 的区别?

List 是集合列表接口,ArrayList 和 LinkedList 都是 List 接口的实现类(都允许添加重复元素)。ArrayList 是动态数组顺序表,顺序表的存储地址是连续的,所以查找比较快,但是插入和删除时由于需要把其它的元素顺序移动,所以比较耗时。LinkedList 是双向链表的数据结构,同时实现了双端队列 Deque 接口,链表节点的存储地址是不连续的,每个存储地址通过指针关联,在查找时需要进行指针遍历节点,所以查找比较慢,而在插入和删除时比较快。

Java clone 相关面试题

问:为什么需要克隆?

答:因为在编程中会遇到一种情况,有一个 DemoBean 的对象实例 demo1 在某一时刻已经包含了一些有效值,此时可能会需要一个和 demo1 完全相同的新对象 demo2 且此后对 demo1 的任何改动都不影响到 demo1 中的值,在 Java 中用简单的赋值语句是不能满足这种需求的,所以我们需要使用其他的途径来保证 demo1 与 demo2 是两个独立的对象且 demo2 的初始值是由 demo1 对象确定的,而克隆就是其官方提供的一种接口定义(至少比主动 new 对象然后取值赋值方便)。

问:浅度克隆(浅拷贝)和深度克隆(深拷贝)的区别是什么?

答:

浅度克隆:被复制对象(一个新的对象实例)的所有变量都含有与原来的对象相同的值,对于基本数据类型的属性复制一份给新产生的对象,对于非基本数据类型的属性仅仅复制一份引用给新产生的对象(新实例中引用类型属性还是指向原来对象引用类型属性)。

深度克隆:被复制对象(一个新的对象实例)的所有变量都含有与原来的对象相同的值,而那些引用其他对象的变量将指向被复制过的新对象(新内存空间),而不再是原有的那些被引用的对象,换言之深度克隆把要复制的对象所引用的对象都复制了一遍,也就是在浅度克隆的基础上,对于要克隆的对象中的非基本数据类型的属性对应的类也实现克隆,这样对于非基本数据类型的属性复制的不是一份引用。

问:String 克隆的特殊性在哪里?StringBuffer 和 StringBuilder 呢?

答:由于基本数据类型都能自动实现深度 clone,引用类型默认实现的是浅度 clone,而 String 是引用类型的一个特例,我们可以和操作基本数据类型一样认为其实现了深度 clone(实质是浅克隆,切记只是一个假象),由于 String 是不可变类,对于 String 类中的很多修改操作都是通过新 new 对象复制处理的,所以当我们修改 clone 前后对象里面 String 属性的值时其实都是属性引用的重新指向操作,自然对 clone 前后对象里 String 属性是没有相互影响的,类似于深度克隆;所以虽然他是引用类型而且我们在深度克隆时无法调用其 clone 方法,但是其不影响我们深度克隆的使用。

如果要实现深度克隆则 StringBuffer 和 StringBuilder 是需要主动特殊处理的,否则就是真正的对象浅克隆,所以处理的办法就是在类的 clone 方法中对 StringBuffer 或者 StringBuilder 属性进行如下主动拷贝操作。

再次强调,这个区别非常重要,即便不是面试题自己开发中也要注意这个坑。

内存相关

问:说说你对java虚拟机的内存分配与回收机制的理解

分为4个方面来介绍内存分配与回收,分别是内存是如何分配的、哪些内存需要回收、在什么情况下执行回收、如何监控和优化GC机制。
java GC(Garbage Collction)垃圾回收机制,是java与C/C++的主要区别之一。通过对jvm中内存进行标记,自主回收一些无用的内存。目前使用的最多的是sun公司jdk中的HotSpot,所以本文也以该jvm作为介绍的根本。
 1.Java内存区域
 在java运行时的数据取里,由jvm管理的内存区域分为多个部分:
在这里插入图片描述
  程序计数器(program counter register):程序计数器是一个比较校的内存单元,用来表示当前程序运行哪里的一个指示器。由于每个线程都由自己的执行顺序,所以程序计数器是线程私有的,每个线程都要由一个自己的程序计数器来指示自己(线程)下一步要执行哪条指令。

如果程序执行的是一个java方法,那么计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地方法(native方法),那么计数器的值为Undefined。由于程序计数器记录的只是当前指令地址,所以不存在内存泄漏的情况,也是jvm内存区域中唯一一个没有OOME(out of memory error)定义的区域。

虚拟机栈(JVM stack):当线程的每个方法在执行的时候都会创建一个栈帧(Stack Frame)用来存储方法中的局部变量、方法出口等,同时会将这个栈帧放入JVM栈中,方法调用完成时,这个栈帧出栈。每个线程都要一个自己的虚拟机栈来保存自己的方法调用时候的数据,因此虚拟机栈也是线程私有的。

虚拟机栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度,抛出StackOverFlowError,不过虚拟机基本上都允许动态扩展虚拟机栈的大小。这样的话线程可以一直申请栈,直到内存不足的时候,会抛出OOME(out of memory error)内存溢出。

本地方法栈(Native Method Stack):本地方法栈与虚拟机栈类似,只是本地方法栈存放的栈帧是在native方法调用的时候产生的。有的虚拟机中会将本地方法栈和虚拟栈放在一起,因此本地方法栈也是线程私有的。

堆(Heap):堆是java GC机制中最重要的区域。堆是为了放置“对象的实例”,对象都是在堆区上分配内存的,堆在逻辑上连续,在物理上不一定连续。所有的线程共用一个堆,堆的大小是可扩展的,如果在执行GC之后,仍没有足够的内存可以分配且堆大小不可再扩展,将会抛出OOME。

方法区(Method Area):又叫静态区,用于存储类的信息、常量池等,逻辑上是堆的一部分,是各个线程共享的区域,为了与堆区分,又叫非堆。在永久代还存在时,方法区被用作永久代。方法区可以选择是否开启垃圾回收。jvm内存不足时会抛出OOME。

直接内存(Direct Memory):直接内存指的是非jvm管理的内存,是机器剩余的内存。用基于通道(Channel)和缓冲区(Buffer)的方式来进行内存分配,用存储在JVM中的DirectByteBuffer来引用,当机器本身内存不足时,也会抛出OOME。

举例说明:

Object obj = new Object();

obj表示一个本地引用,存储在jvm栈的本地变量表中,new Object()作为一个对象放在堆中,Object类的类型信息(接口,方法,对象类型等)放在堆中,而这些类型信息的地址放在方法区中。

这里需要知道如何通过引用访问到具体对象,也就是通过obj引用如何找到new出来的这个Object()对象,主要有两种方法,通过句柄和通过直接指针访问。
  通过句柄: 在java堆中会专门有一块区域被划分为句柄池,一个引用的背后是一个对象实例数据(java堆中)的指针和对象类型信息(方法区中)的指针,而这两个指针都是在java堆上的。这种方法是优势是较为稳定,但是速度不是很快
在这里插入图片描述
  通过直接指针: 一个引用背后是一个对象的实例数据,这个实例数据里面包含了“到对象类型信息的指针”。这种方式的优势是速度快,在HotSpot中用的就是这种方式。
在这里插入图片描述

问:Java内存是如何分配和回收的?

答:内存分配主要是在堆上的分配,如前面new出来的对象,放在堆上,但是现代技术也支持在栈上分配,较为少见,本文不考虑。分配内存与回收内存的标准是八个字:分代分配,分代回收。那么这个代是什么呢?

jvm中将对象根据存活的时间划分为三代:年轻代(Young Generation)、年老代(Old Generation)和永久代(Permannent Generation)。在jdk1.8中已经不再使用永久代,因此这里不再介绍。
在这里插入图片描述
  年轻代:又叫新生代,所有新生成的对象都是先放在年轻代。年轻代分三个区,一个Eden区,两个Survivor区,一个叫From,一个叫To(这个名字是动态变化的)。当Eden中满时,执行Minor GC将消亡的对象清理掉,仍存活的对象将被复制到Survivor中的From区,清空Eden。当这个From区满的时候,仍存活的对象将被复制到To区,清空From区,并且原From区变为To区,原To区变为From区,这样的目的是保证To区一直为空。当From区满无对象可清理或者From-To区交换的次数超过设定(HotSpot默认为15,通过-XX:MaxTenuringThreashold控制)的时候,仍存活的对象进入老年代。年轻代中Eden和Servivor的比例通过-XX:SerivorRation参数来配置,默认为8,也就时说Eden:From:To=8:1:1。年轻代的回收方式叫做Minor GC,又叫停止-复制清理法。这种方法在回收的时候,需要暂停其他所有线程的执行,导致效率很低,现在虽然有优化,但是仅仅是将停止的时间变短,并没有彻底取消这个停止。

年老代:年老代的空间较大,当年老代内存不足时,将执行Major GC也叫Full GC。如果对象比较大,可能会直接分配到老年代上而不经过年轻代。用-XX:pertenureSizeThreashold来设定这个值,大于这个的对象会直接分配到老年代上。

问:垃圾收集器工作原理

答:  在GC机制中,起作用的是垃圾收集器。HotSpot1.6中使用的垃圾收集器如下(有连线表示有联系):
在这里插入图片描述

Serial收集器:新生代(年轻代)收集器,使用停止-复制算法,使用一个线程进行GC,其他工作线程暂停。

ParNew收起:新生代收集器,使用停止-复制算法,Serial收集器的多线程版,用多个线程进行GC,其他工作线程暂停,关注缩短垃圾收集时间。

Parallel Scavenge收集器:新生代收集器,使用停止-复制算法,关注CPU吞吐量,即运行用户代码的时间/总时间。

Serial Old收集器:年老代收集器,单线程收集器,使用标记-整理算法(整理的方法包括sweep清理和compact压缩,标记-清理是先标记需要回收的对象,在标记完成后统一清楚标记的对象,这样清理之后空闲的内存是不连续的;标记-压缩是先标记需要回收的对象,把存活的对象都向一端移动,然后直接清理掉端边界以外的内存,这样清理之后空闲的内存是连续的)。

Parallel Old收集器:老年代收集器,多线程收集器,使用标记-整理算法(整理的方法包括summary汇总和compact压缩,标记-压缩与Serial Old一样,标记-汇总是将幸存的对象复制到预先准备好的区域,再清理之前的对象)。

CMS(Concurrent Mark Sweep)收集器:老年老代收集器,多线程收集器,关注最短回收时间停顿,使用标记-清除算法,用户线程可以和GC线程同时工作。

问:简单说说 Java 中内存泄漏与内存溢出的区别?

答:这内存溢出(OutOfMemory)是指程序在申请内存时没有足够的内存空间供其使用。内存溢出根据申请不同的内存区域又可以分为heapsize OOM,PerGen OOM,DirecBuffer OOM和StackOverflowError . 内存泄露(MemoryLeak)是指程序在申请内存后无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存迟早会被消耗尽,所以内存泄漏最终可能会导致内存溢出。

内存泄漏本身一般对业务逻辑不会产生什么危害,作为一般的用户在频次不高的情况下根本感觉不到内存泄漏的存在,真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存,所以频次不高和占用内存不大的泄露一般都比较难以发现定位,如果需要定位分析内存泄漏可以采用一些第三方工具辅助,譬如 MAT 等。

内存溢出出现的原因一般比较多,譬如内存中一次加载的数据量过于庞大,启动参数内存值设定的过小,内存持续泄漏导致内存用光等。解决内存溢出可以通过修改 JVM 启动参数(-Xms/-Xmx 等,不过一般不建议),检查分析代码找出庞大数据或者泄漏点。

问:Java 对象使用后设置为 null 会减少内存占用吗?

答:不会,设置为 null 只是栈中指向的引用为 null,但是 new 出来的对象还是存在于堆里面的,按照目前的 GC 算法,要等 survior1 or survior2 满的时候 JVM 才会调用 GC 命令清除对应 survior 区的对象,将没有栈指向的对象给回收掉。所以回收内存不是实时的,要看 survior区的大小和应用中创建对象的速度来看。所以,可以认为用完的变量设为 null 有助于 java 的 gc 更早的将无用的内存收回,仅此而已。

Java类加载机制

什么是类的加载?

答:类的加载机制在整个java程序运行期间处于一个什么环节,下面使用一张图来表示:
在这里插入图片描述
从上图可以看,java文件通过编译器变成了.class文件,接下来类加载器又将这些.class文件加载到JVM中。其中类装载器的作用其实就是类的加载。今天我们要讨论的就是这个环节。有了这个印象之后我们再来看类的加载的概念:
其实可以一句话来解释:类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。
到现在为止,我们基本上对类加载机制处于整个程序运行的环节位置,还有类加载机制的概念有了基本的印象。在类加载.class文件之前,还有两个问题需要我们去弄清楚:
1、在什么时候才会启动类加载器?

其实,类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。
2、从哪个地方去加载.class文件

在这里进行一个简单的分类。例举了5个来源

(1)本地磁盘

(2)网上加载.class文件(Applet)

(3)从数据库中

(4)压缩文件中(ZAR,jar等)

(5)从其他文件生成的(JSP应用)

类加载的过程

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。它们的顺序如下图所示:
在这里插入图片描述

其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
1、加载

”加载“是”类加机制”的第一个过程,在加载阶段,虚拟机主要完成三件事:

(1)通过一个类的全限定名来获取其定义的二进制字节流

(2)将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构

(3)在堆中生成一个代表这个类的Class对象,作为方法区中这些数据的访问入口。

相对于类加载的其他阶段而言,加载阶段是可控性最强的阶段,因为程序员可以使用系统的类加载器加载,还可以使用自己的类加载器加载。我们在最后一部分会详细介绍这个类加载器。在这里我们只需要知道类加载器的作用就是上面虚拟机需要完成的三件事,仅此而已就好了。

2、验证

验证的主要作用就是确保被加载的类的正确性。也是连接阶段的第一步。说白了也就是我们加载好的.class文件不能对我们的虚拟机有危害,所以先检测验证一下。他主要是完成四个阶段的验证:

(1)文件格式的验证:验证.class文件字节流是否符合class文件的格式的规范,并且能够被当前版本的虚拟机处理。这里面主要对魔数、主版本号、常量池等等的校验(魔数、主版本号都是.class文件里面包含的数据信息、在这里可以不用理解)。

(2)元数据验证:主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求,比如说验证这个类是不是有父类,类中的字段方法是不是和父类冲突等等。

(3)字节码验证:这是整个验证过程最复杂的阶段,主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型做出验证后,这个阶段主要对类的方法做出分析,保证类的方法在运行时不会做出威海虚拟机安全的事。

(4)符号引用验证:它是验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用的时候。主要是对类自身以外的信息进行校验。目的是确保解析动作能够完成。

对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么我们就没有必要去验证,毕竟验证需要花费一定的的时间。当然我们可以使用-Xverfity:none来关闭大部分的验证。

3、准备

准备阶段主要为类变量分配内存并设置初始值。这些内存都在方法区分配。在这个阶段我们只需要注意两点就好了,也就是类变量和初始值两个关键词:

(1)类变量(static)会分配内存,但是实例变量不会,实例变量主要随着对象的实例化一块分配到java堆中,

(2)这里的初始值指的是数据类型默认值,而不是代码中被显示赋予的值。比如

public static int value = 1; //在这里准备阶段过后的value值为0,而不是1。赋值为1的动作在初始化阶段。

当然还有其他的默认值。
在这里插入图片描述

注意,在上面value是被static所修饰的准备阶段之后是0,但是如果同时被final和static修饰准备阶段之后就是1了。我们可以理解为static final在编译器就将结果放入调用它的类的常量池中了。

4、解析

解析阶段主要是虚拟机将常量池中的符号引用转化为直接引用的过程。什么是符号应用和直接引用呢?

符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量,只要是能无歧义的定位到目标就好,就好比在班级中,老师可以用张三来代表你,也可以用你的学号来代表你,但无论任何方式这些都只是一个代号(符号),这个代号指向你(符号引用)
直接引用:直接引用是可以指向目标的指针、相对偏移量或者是一个能直接或间接定位到目标的句柄。和虚拟机实现的内存有关,不同的虚拟机直接引用一般不同。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

5、初始化

这是类加载机制的最后一步,在这个阶段,java程序代码才开始真正执行。我们知道,在准备阶段已经为类变量赋过一次值。在初始化阶端,程序员可以根据自己的需求来赋值了。一句话描述这个阶段就是执行类构造器< clinit >()方法的过程。

在初始化阶段,主要为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:

①声明类变量是指定初始值

②使用静态代码块为类变量指定初始值

JVM初始化步骤

1、假如这个类还没有被加载和连接,则程序先加载并连接该类

2、假如该类的直接父类还没有被初始化,则先初始化其直接父类

3、假如类中有初始化语句,则系统依次执行这些初始化语句

类初始化时机:只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

创建类的实例,也就是new的方式
访问某个类或接口的静态变量,或者对该静态变量赋值
调用类的静态方法
反射(如 Class.forName(“com.dong.Test”))
初始化某个类的子类,则其父类也会被初始化
Java虚拟机启动时被标明为启动类的类( JavaTest),直接使用 java.exe命令来运行某个主类.

问:简单说说你对 ClassLoader 的理解?

1、Java语言系统自带有三个类加载器: Bootstrap ClassLoader
:最顶层的加载类,主要加载核心类库,也就是我们环境变量下面%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。另外需要注意的是可以通过启动jvm时指定-Xbootclasspath和路径来改变Bootstrap ClassLoader的加载目录。比如java -Xbootclasspath/a:path被指定的文件追加到默认的bootstrap路径中。我们可以打开我的电脑,在上面的目录下查看,看看这些jar包是不是存在于这个目录。
Extention ClassLoader \ :扩展的类加载器,加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。还可以加载-D java.ext.dirs选项指定的目录。
Appclass Loader:也称为SystemAppClass。 加载当前应用的classpath的所有类。
我们看到java为我们提供了三个类加载器,应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。这三种类加载器的加载顺序是什么呢?

Bootstrap ClassLoader > Extention ClassLoader > Appclass Loader
在这里插入图片描述

一张图来看一下他们的层次关系

问:Java 虚拟机是如何判断两个 Class 类是相同的?

答:Java 虚拟机不仅要看类的全名是否相同(含包名路径),还要看加载此类的类加载器是否一样,只有两者都相同的情况下才认为两个类是相同的。即便是同样的字节代码,被不同的类加载器加载之后所得到的类也是不同的,譬如一个 Java 类 cn.dong.Test 在编译后生成了字节码文件 Test.class,两个不同的类加载器 ClassLoaderA 和 ClassLoaderB 分别读取了这个 Test.class 文件,然后各自定义出一个 java.lang.Class 类的实例来表示这个类,这两个实例是不相同的,因为对于 Java 虚拟机来说它们是不同的类,这时候如果试图对这两个类的对象进行相互赋值则会抛出 ClassCastException 运行时异常。这在做插件化动态加载中要尤其注意.

线程相关面试题

问:线程有多少种状态,以及他们之间是如何切换的?

Java中的线程的生命周期大体可分为5种状态,如下图所示:
在这里插入图片描述

线程状态切换如下图所示:
在这里插入图片描述

  1. 新建(NEW):新创建了一个线程对象,并没有调用start()方法之前。

  2. 可运行(RUNNABLE):也就是就绪状态,调用start()方法之后线程就进入就绪状态, 但是并不是说只要调用start()方法线程就马上变为当前线程,在变为当前线程之前都是为就绪状态。值得一提的是,线程在睡眠和挂起中恢复的时候也会进入就绪状态。线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu 的使用权 。

  3. 运行(RUNNING):可运行状态(runnable)的线程获得了cpu 时间片(timeslice) ,执行程序代码。:线程被设置为当前线程,开始执行run()方法。就是线程进入运行状态

  4. 阻塞(BLOCKED):阻塞状态是指线程因为某种原因放弃了cpu 使用权,也即让出了cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu timeslice 转到运行(running)状态。线程被暂停,比如说调用sleep()方法后线程就进入阻塞状态。

阻塞的情况分三种:

(一). 等待阻塞:运行(running)的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。
(二). 同步阻塞:运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池(lock pool)中。
(三). 其他阻塞:运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行(runnable)状态。
5. 死亡(DEAD):线程执行结束,线程run()、main() 方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。

说说什么是原子性、可见性和有序性

1.原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

一个很经典的例子就是银行账户转账问题:

比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。

所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

同样地反映到并发编程中会出现什么结果呢?

举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?

i = 9;

假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。

那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。

2.可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

举个简单的例子,看下面这段代码:

//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.

这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3.有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。 计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排(Instruction Reorder),一般分以下3种 \

编译器优化的重排

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

指令并行的重排

现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序

内存系统的重排

由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
举个简单的例子,看下面这段代码:

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排。

下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。

但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4

这段代码有4个语句,那么可能的一个执行顺序是:

那么可不可能是这个执行顺序呢: 语句2 语句1 语句4 语句3

不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。

也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

说说你对Java内存模型的理解

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图
在这里插入图片描述

设计模式相关

请写出一个懒汉模式的单例
public final class Singleton {

    /**
     * 对保存实例的变量添加volatile的修饰
     */
    private volatile static Singleton instance = null;

    private Singleton() {

    }
    
    public final static Singleton getInstance() {

         //先检查实例是否存在,如果不存在才进入下面的同步块
        if (instance == null) {
            //同步块,线程安全的创建实例
            synchronized(Singleton.class) {
                //再次检查实例是否存在,如果不存在才真的创建实例
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

问: 上面instance为什么需要声名volatile?
原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。因为instance = new Singleton();可以分为以下3步完成(伪代码)

memory = allocate(); //1.分配对象内存空间
instance(memory);    //2.初始化对象
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null

由于步骤1和步骤2间可能会重排序,如下:

memory = allocate(); //1.分配对象内存空间
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);    //2.初始化对象

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题.所以我们使用volatile禁止instance变量被执行指令重排优化即可。

问:说说 Condition 与传统线程协作的区别?

答:Condition 可以说是传统 Object.wait() 和 Object.natify() 的加强版,能够更加精细的控制多线程的休眠与唤醒。对于同一个锁我们可以创建多个 Condition(即多个监视器),从而在不同的情况下使用不同的 Condition。

举个例子,假设我们要实现多线程读写同一个缓冲区,当向缓冲区中写入数据之后唤醒读线程,当从缓冲区读出数据之后唤醒写线程。如果使用传统 Object.wait()/Object.notify()/Object.notifyAll() 实现该缓冲区,当向缓冲区写入数据后唤醒读线程时是没法通过 Object.notify()/Object.notifyAll() 明确的指定唤醒读线程,而只能通过 Object.notifyAll() 唤醒所有线程。但是通过 Condition 就可以明确的指定唤醒读线程。
下面的程序就演示了 Condition 特有的能力,我们模拟日常上班,每个启动线程都是一个岗位,上班以后 boss 发布一个 plain,然后 manager 赶紧顶上去接锅,然后 manager 把 boss 的 plain 发给 teamLeader,teamLeader 又把 plain 发给 coder,如下就是这么一种流程:


public class Test {
    public static void main(String[] args) throws InterruptedException {
        final OfficeWork work = new OfficeWork();
        new Thread() {
            public void run() {
                work.coderCmd();
            }
        }.start();

        new Thread() {
            public void run() {
                work.teamLeaderCmd();
            }
        }.start();

        new Thread() {
            public void run() {
                work.managerCmd();
            }
        }.start();

        Thread.sleep(2000);
        work.bossCmd();
    }

    static class OfficeWork {
        private Lock lock = new ReentrantLock();
        private Condition cond1 = lock.newCondition();
        private Condition cond2 = lock.newCondition();
        private Condition cond3 = lock.newCondition();

        public void bossCmd() {
            try {
                lock.lock();
                cond1.signal();
                System.out.println("Boss have a plain!");
            } finally {
                lock.unlock();
            }
        }

        public void managerCmd() {
            try {
                lock.lock();
                cond1.await();
                Thread.sleep(500);
                System.out.println("Manager received Cmd!");
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                cond3.signal();
                lock.unlock();
            }
        }

        public void coderCmd() {
            try {
                lock.lock();
                cond2.await();
                Thread.sleep(500);
                System.out.println("Coder received Cmd!");
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                cond1.signal();
                lock.unlock();
            }
        }

        public void teamLeaderCmd() {
            try {
                lock.lock();
                cond3.await();
                Thread.sleep(500);
                System.out.println("TeamLeader received Cmd!");
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                cond2.signal();
                lock.unlock();
            }
        }
    }
}
问:Java 多线程文件读写操作怎么保证并发安全?

答:多线程文件并发安全其实就是在考察线程并发安全,最锉的方式就是使用 wait/notify、Condition、synchronized、ReentrantLock 等方式,这些方式默认都是排它操作(排他锁),也就是说默认情况下同一时刻只能有一个线程可以对文件进行操作,所以可以保证并发文件操作的安全性,但是在并发读数量远多于写数量的情况下性能却不那么好。因此推荐使用 ReadWriteLock 的实现类 ReentrantReadWriteLock,它也是 Lock 的一种实现,允许多个读线程同时访问,但不允许写线程和读线程、写线程和写线程同时访问。所以相对于排他锁来说提高了并发效率。ReentrantReadWriteLock 读写锁里面维护了两个继承自 Lock 的锁,一个用于读操作(ReadLock),一个用于写操作(WriteLock)。

下面给出高效读写操作的样例:

class DataCache {
    private Map<String, String> cachedMap = new HashMap<>();

    private ReadWriteLock lock = new ReentrantReadWriteLock();
    private final Lock readLock = lock.readLock();
    private final Lock writeLock = lock.writeLock();

    public long readSize() {
        try {
            readLock.lock();
            mockTimeConsumingOpt();
            return cachedMap.size();
        } finally {
            readLock.unlock();
        }
    }

    public long write(String key, String value) {
        try {
            writeLock.lock();
            mockTimeConsumingOpt();
            cachedMap.put(key, value);
            return cachedMap.size();
        } finally {
            writeLock.unlock();
        }
    }

    private void mockTimeConsumingOpt() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

class Reader extends Thread {
    public DataCache dataCache;

    public Reader(String name, DataCache dataCache) {
        super(name);
        this.dataCache = dataCache;
    }

    @Override
    public void run() {
        String name = Thread.currentThread().getName();
        long result =  dataCache.readSize();
        System.out.println(name + " read current cache size is:" + result);
    }
}

class Writer extends Thread {

    public DataCache dataCache;

    public Writer(String str, DataCache dataCache) {
        super(str);
        this.dataCache = dataCache;
    }

    @Override
    public void run() {
        String name = Thread.currentThread().getName();
        String result = "" + dataCache.write(name, "DATA-"+name);
        System.out.println(name + " write to current cache!");
    }
}

public class Test {
    public static void main(String[] args) {
        final DataCache dataCache = new DataCache();

        ArrayList<Thread> worker = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            if (i % 2 == 0) {
                Writer writer = new Writer("Writer"+i, dataCache);
                worker.add(writer);
            } else {
                Reader reader = new Reader("Reader"+i, dataCache);
                worker.add(reader);
            }
        }

        for (int i = 0; i < worker.size(); i++) {
            worker.get(i).start();
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值