1、在 Java 中,什么时候用重载,什么时候用重写?
(1)重载是多态的集中体现,在类中,要以统一的方式处理不同类型数据的时候,可以用重载。
(2)重写的使用是建立在继承关系上的,子类在继承父类的基础上,增加新的功能,可以用重写。
(3)简单总结:
重载是多样性,重写是增强剂;
目的是提高程序的多样性和健壮性,以适配不同场景使用时,使用重载进行扩展;
目的是在不修改原方法及源代码的基础上对方法进行扩展或增强时,使用重写;
生活例子:
你想吃一碗面,我给你提供了拉面,炒面,刀削面,担担面供你选择,这是重载;
你想吃一碗面,我不但给你端来了面,还给你加了青菜,加了鸡蛋,这个是重写;
设计模式:
cglib 实现动态代理,核心原理用的就是方法的重写;
详细解答:
Java 的重载 (overload) 最重要的应用场景就是构造器的重载,构造器重载后,提供多种形参形式的构造器,可以应对不同的业务需求,加强程序的健壮性和可扩展性,比如我们最近学习的 Spring 源码中的 ClassPathXmlApplicationContext,它的构造函数使用重载一共提供了 10 个构造函数,这样就为业务的选择提供了多选择性。在应用到方法中时,主要是为了增强方法的健壮性和可扩展性,比如我们在开发中常用的各种工具类,比如我目前工作中的短信工具类 SMSUtil, 发短信的方法就会使用重载,针对不同业务场景下的不同形参,提供短信发送方法,这样提高了工具类的扩展性和健壮性。
总结:重载必须要修改方法 (构造器) 的形参列表,可以修改方法的返回值类型,也可以修改方法的异常信息即访问权限;使用范围是在同一个类中,目的是对方法 (构造器) 进行功能扩展,以应对多业务场景的不同使用需求。提高程序的健壮性和扩展性。
java 的重写 (override) 只要用于子类对父类方法的扩展或修改,但是在我们开发中,为了避免程序混乱,重写一般都是为了方法的扩展,比如在 cglib 方式实现的动态代理中,代理类就是继承了目标类,对目标类的方法进行重写,同时在方法前后进行切面织入。
总结:方法重写时,参数列表,返回值得类型是一定不能修改的,异常可以减少或者删除,但是不能抛出新的异常或者更广的异常,方法的访问权限可以降低限制,但是不能做更严格的限制。
(4)在里氏替换原则中,子类对父类的方法尽量不要重写和重载。(我们可以采用 final 的手段强制来遵循)
2、举例说明什么情况下会更倾向于使用抽象类而不是接口?
接口和抽象类都遵循” 面向接口而不是实现编码” 设计原则,它可以增加代码的灵活性,可以适应不断变化的需求。下面有几个点可以帮助你回答这个问题:在 Java 中,你只能继承一个类,但可以实现多个接口。所以一旦你继承了一个类,你就失去了继承其他类的机会了。
接口通常被用来表示附属描述或行为如: Runnable 、 Clonable 、 Serializable 等等,因此当你使用抽象类来表示行为时,你的类就不能同时是 Runnable 和 Clonable(注:这里的意思是指如果把 Runnable 等实现为抽象类的情况) ,因为在 Java 中你不能继承两个类,但当你使用接口时,你的类就可以同时拥有多个不同的行为。
在一些对时间要求比较高的应用中,倾向于使用抽象类,它会比接口稍快一点。如果希望把一系列行为都规范在类继承层次内,并且可以更好地在同一个地方进行编码,那么抽象类是一个更好的选择。有时,接口和抽象类可以一起使用,接口中定义函数,而在抽象类中定义默认的实现。
3、实例化对象有哪几种方式
- new
- clone()
- 通过反射机制创建
//用 Class.forName方法获取类,在调用类的newinstance()方法
Class<?> cls = Class.forName("com.dao.User");
User u = (User)cls.newInstance();
- 序列化反序列化
//将一个对象实例化后,进行序列化,再反序列化,也可以获得一个对象(远程通信的场景下使用)
ObjectOutputStream out = new ObjectOutputStream (new FileOutputStream("D:/data.txt"));
//序列化对象
out.writeObject(user1);
out.close();
//反序列化对象
ObjectInputStream in = new ObjectInputStream(new FileInputStream("D:/data.txt"));
User user2 = (User) in.readObject();
System.out.println("反序列化user:" + user2);
in.close();
4、byte 类型 127+1 等于多少
byte 的范围是 - 128~127。
字节长度为 8 位,最左边的是符号位,而 127 的二进制为 01111111,所以执行 + 1 操作时,01111111 变为 10000000。
大家知道,计算机中存储负数,存的是补码的兴衰。左边第一位为符号位。
那么负数的补码转换成十进制如下:
一个数如果为正,则它的原码、反码、补码相同;一个正数的补码,将其转化为十进制,可以直接转换。
已知一个负数的补码,将其转换为十进制数,步骤如下:
- 先对各位取反;
- 将其转换为十进制数;
- 加上负号,再减去 1;
例如 10000000,最高位是 1,是负数,①对各位取反得 01111111,转换为十进制就是 127,加上负号得 - 127,再减去 1 得 - 128;
5、Java 容器都有哪些?
(1)Collection
① set
HashSet、TreeSet
② list
ArrayList、LinkedList、Vector
(2)Map
HashMap、HashTable、TreeMap
6、Collection 和 Collections 有什么区别?
(1)Collection 是最基本的集合接口,Collection 派生了两个子接口 list 和 set,分别定义了两种不同的存储方式。
(2)Collections 是一个包装类,它包含各种有关集合操作的静态方法(对集合的搜索、排序、线程安全化等)。
此类不能实例化,就像一个工具类,服务于 Collection 框架。
7、list 与 Set 区别
(1)List 简介
实际上有两种 List:一种是基本的 ArrayList, 其优点在于随机访问元素,另一种是 LinkedList, 它并不是为快速随机访问设计的,而是快速的插入或删除。
ArrayList:由数组实现的 List。允许对元素进行快速随机访问,但是向 List 中间插入与移除元素的速度很慢。
LinkedList :对顺序访问进行了优化,向 List 中间插入与删除的开销并不大。随机访问则相对较慢。
还具有下列方 法:addFirst(), addLast(), getFirst(), getLast(), removeFirst() 和 removeLast(), 这些方法 (没有在任何接口或基类中定义过) 使得 LinkedList 可以当作堆栈、队列和双向队列使用。
(2)Set 简介
Set 具有与 Collection 完全一样的接口,因此没有任何额外的功能。实际上 Set 就是 Collection, 只是行为不同。这是继承与多态思想的典型应用:表现不同的行为。Set 不保存重复的元素 (至于如何判断元素相同则较为负责)
Set : 存入 Set 的每个元素都必须是唯一的,因为 Set 不保存重复元素。加入 Set 的元素必须定义 equals() 方法以确保对象的唯一性。Set 与 Collection 有完全一样的接口。Set 接口不保证维护元素的次序。
HashSet:为快速查找设计的 Set。存入 HashSet 的对象必须定义 hashCode()。
TreeSet: 保存次序的 Set, 底层为树结构。使用它可以从 Set 中提取有序的序列。
(3)list 与 Set 区别
① List,Set 都是继承自 Collection 接口
② List 特点:元素有放入顺序,元素可重复 ,Set 特点:元素无放入顺序,元素不可重复,重复元素会覆盖掉,(元素虽然无放入顺序,但是元素在 set 中的位置是有该元素的 HashCode 决定的,其位置其实是固定的,加入 Set 的 Object 必须定义 equals() 方法 ,另外 list 支持 for 循环,也就是通过下标来遍历,也可以用迭代器,但是 set 只能用迭代,因为他无序,无法用下标来取得想要的值。)
③ Set 和 List 对比:
Set:检索元素效率低下,删除和插入效率高,插入和删除不会引起元素位置改变。
List:和数组类似,List 可以动态增长,查找元素效率高,插入删除元素效率低,因为会引起其他元素位置改变。
8、HashMap 和 Hashtable 有什么区别?
- HashMap 是线程不安全的,HashTable 是线程安全的;
- HashMap 中允许键和值为 null,HashTable 不允许;
- HashMap 的默认容器是 16,为 2 倍扩容,HashTable 默认是 11,为 2 倍 + 1 扩容;
9、说一下 HashMap 的实现原理?
(1)简介
HashMap 基于 map 接口,元素以键值对方式存储,允许有 null 值,HashMap 是线程不安全的。
(2)基本属性
初始化大小,默认 16,2 倍扩容;
负载因子 0.75;
初始化的默认数组;
size
threshold。判断是否需要调整 hashmap 容量
(3)HashMap 的存储结构
JDK1.7 中采用数组 + 链表的存储形式。
HashMap 采取 Entry 数组来存储 key-value,每一个键值对组成了一个 Entry 实体,Entry 类时机上是一个单向的链表结构,它具有 next 指针,指向下一个 Entry 实体,以此来解决 Hash 冲突的问题。
HashMap 实现一个内部类 Entry,重要的属性有 hash、key、value、next。
JDK1.8 中采用数据 + 链表 + 红黑树的存储形式。当链表长度超过阈值(8)时,将链表转换为红黑树。在性能上进一步得到提升。
10、set 有哪些实现类?
(1)HashSet
HashSet 是 set 接口的实现类,set 下面最主要的实现类就是 HashSet(也就是用的最多的),此外还有 LinkedHashSet 和 TreeSet。
HashSet 是无序的、不可重复的。通过对象的 hashCode 和 equals 方法保证对象的唯一性。
HashSet 内部的存储结构是哈希表,是线程不安全的。
(2)TreeSet
TreeSet 对元素进行排序的方式:
元素自身具备比较功能,需要实现 Comparable 接口,并覆盖 compareTo 方法。
元素自身不具备比较功能,需要实现 Comparator 接口,并覆盖 compare 方法。
(3)LinkedHashSet
LinkedHashSet 是一种有序的 Set 集合,即其元素的存入和输出的顺序是相同的。
11、说一下 HashSet 的实现原理?
HashSet 实际上是一个 HashMap 实例,数据存储结构都是数组 + 链表。
HashSet 是基于 HashMap 实现的,HashSet 中的元素都存放在 HashMap 的 key 上面,而 value 都是一个统一的对象 PRESENT。
private static final Object PRESENT = new Object();
- 1
- 2
HashSet 中 add 方法调用的是底层 HashMap 中的 put 方法,put 方法要判断插入值是否存在,而 HashSet 的 add 方法,首先判断元素是否存在,如果存在则插入,如果不存在则不插入,这样就保证了 HashSet 中不存在重复值。
通过对象的 hashCode 和 equals 方法保证对象的唯一性。
12、ArrayList 和 LinkedList 的区别是什么?
ArrayList 是动态数组的数据结构实现,查找和遍历的效率较高;
LinkedList 是双向链表的数据结构,增加和删除的效率较高;
13、如何实现数组和 List 之间的转换?
String[] arr = {"zs","ls","ww"};
List<String> list = Arrays.asList(arr);
System.out.println(list);
ArrayList<String> list1 = new ArrayList<String>();
list1.add("张三");
list1.add("李四");
list1.add("王五");
String[] arr1 = list1.toArray(new String[list1.size()]);
System.out.println(arr1);
for(int i = 0; i < arr1.length; i++){
System.out.println(arr1[i]);
}
14、在 Queue 中 poll() 和 remove() 有什么区别?
(1)offer() 和 add() 区别:
增加新项时,如果队列满了,add 会抛出异常,offer 返回 false。
(2)poll() 和 remove() 区别:
poll() 和 remove() 都是从队列中删除第一个元素,remove 抛出异常,poll 返回 null。
(3)peek() 和 element()区别:
peek() 和 element()用于查询队列头部元素,为空时 element 抛出异常,peek 返回 null。
15、哪些集合类是线程安全的
Vector:就比 Arraylist 多了个同步化机制(线程安全)。
Stack:栈,也是线程安全的,继承于 Vector。
Hashtable:就比 Hashmap 多了个线程安全。
ConcurrentHashMap: 是一种高效但是线程安全的集合。
16、迭代器 Iterator 是什么?
为了方便的处理集合中的元素, Java 中出现了一个对象, 该对象提供了一些方法专门处理集合中的元素. 例如删除和获取集合中的元素. 该对象就叫做迭代器 (Iterator)。
17、Iterator 怎么使用?有什么特点?
Iterator 接口源码中的方法:
- java.lang.Iterable 接口被 java.util.Collection 接口继承,java.util.Collection 接口的 iterator() 方法返回一个 Iterator 对象
- next() 方法获得集合中的下一个元素
- hasNext() 检查集合中是否还有元素
- remove() 方法将迭代器新返回的元素删除
18、Iterator 和 ListIterator 有什么区别?
(1)ListIterator 继承 Iterator
(2)ListIterator 比 Iterator 多方法
- add(E e) 将指定的元素插入列表,插入位置为迭代器当前位置之前
- set(E e) 迭代器返回的最后一个元素替换参数 e
- hasPrevious() 迭代器当前位置,反向遍历集合是否含有元素
- previous() 迭代器当前位置,反向遍历集合,下一个元素
- previousIndex() 迭代器当前位置,反向遍历集合,返回下一个元素的下标
- nextIndex() 迭代器当前位置,返回下一个元素的下标
(3)使用范围不同,Iterator 可以迭代所有集合;ListIterator 只能用于 List 及其子类
- ListIterator 有 add 方法,可以向 List 中添加对象;Iterator 不能
- ListIterator 有 hasPrevious() 和 previous() 方法,可以实现逆向遍历;Iterator 不可以
- ListIterator 有 nextIndex() 和 previousIndex() 方法,可定位当前索引的位置;Iterator 不可以
- ListIterator 有 set() 方法,可以实现对 List 的修改;Iterator 仅能遍历,不能修改。
19、怎么确保一个集合不能被修改?
我们很容易想到用 final 关键字进行修饰,我们都知道
final 关键字可以修饰类,方法,成员变量,final 修饰的类不能被继承,final 修饰的方法不能被重写,final 修饰的成员变量必须初始化值,如果这个成员变量是基本数据类型,表示这个变量的值是不可改变的,如果说这个成员变量是引用类型,则表示这个引用的地址值是不能改变的,但是这个引用所指向的对象里面的内容还是可以改变的。
那么,我们怎么确保一个集合不能被修改?首先我们要清楚,集合(map,set,list…)都是引用类型,所以我们如果用 final 修饰的话,集合里面的内容还是可以修改的。
我们可以做一个实验:
可以看到:我们用 final 关键字定义了一个 map 集合,这时候我们往集合里面传值,第一个键值对 1,1;我们再修改后,可以把键为 1 的值改为 100,说明我们是可以修改 map 集合的值的。
那我们应该怎么做才能确保集合不被修改呢?
我们可以采用 Collections 包下的 unmodifiableMap 方法,通过这个方法返回的 map, 是不可以修改的。他会报 java.lang.UnsupportedOperationException 错。
同理:Collections 包也提供了对 list 和 set 集合的方法。
Collections.unmodifiableList(List)
Collections.unmodifiableSet(Set)
20、队列和栈是什么?有什么区别?
(1)队列先进先出,栈先进后出。
(2)遍历数据速度不同。
栈只能从头部取数据 也就最先放入的需要遍历整个栈最后才能取出来,而且在遍历数据的时候还得为数据开辟临时空间,保持数据在遍历前的一致性;
队列则不同,他基于地址指针进行遍历,而且可以从头或尾部开始遍历,但不能同时遍历,无需开辟临时空间,因为在遍历的过程中不影像数据结构,速度要快的多。
21、Java8 开始 ConcurrentHashMap, 为什么舍弃分段锁?
ConcurrentHashMap 的原理是引用了内部的 Segment (ReentrantLock) 分段锁,保证在操作不同段 map 的时候, 可以并发执行, 操作同段 map 的时候,进行锁的竞争和等待。从而达到线程安全, 且效率大于 synchronized。
但是在 Java 8 之后, JDK 却弃用了这个策略,重新使用了 synchronized+CAS。
弃用原因
通过 JDK 的源码和官方文档看来, 他们认为的弃用分段锁的原因由以下几点:
加入多个分段锁浪费内存空间。
生产环境中, map 在放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的长时间等待。
为了提高 GC 的效率
新的同步方案
既然弃用了分段锁, 那么一定由新的线程安全方案, 我们来看看源码是怎么解决线程安全的呢?(源码保留了 segment 代码, 但并没有使用)。
22、ConcurrentHashMap(JDK1.8) 为什么要使用 synchronized 而不是如 ReentranLock 这样的可重入锁?
我想从下面几个角度讨论这个问题:
(1)锁的粒度
首先锁的粒度并没有变粗,甚至变得更细了。每当扩容一次,ConcurrentHashMap 的并发度就扩大一倍。
(2)Hash 冲突
JDK1.7 中,ConcurrentHashMap 从过二次 hash 的方式(Segment -> HashEntry)能够快速的找到查找的元素。在 1.8 中通过链表加红黑树的形式弥补了 put、get 时的性能差距。
JDK1.8 中,在 ConcurrentHashmap 进行扩容时,其他线程可以通过检测数组中的节点决定是否对这条链表(红黑树)进行扩容,减小了扩容的粒度,提高了扩容的效率。
下面是我对面试中的那个问题的一下看法。
为什么是 synchronized,而不是 ReentranLock
(1)减少内存开销
假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
(2)获得 JVM 的支持
可重入锁毕竟是 API 这个级别的,后续的性能优化空间很小。
synchronized 则是 JVM 直接支持的,JVM 能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。这就使得 synchronized 能够随着 JDK 版本的升级而不改动代码的前提下获得性能上的提升。
23、concurrentHashMap 和 HashTable 有什么区别
concurrentHashMap 融合了 hashmap 和 hashtable 的优势,hashmap 是不同步的,但是单线程情况下效率高,hashtable 是同步的同步情况下保证程序执行的正确性。
但 hashtable 每次同步执行的时候都要锁住整个结构,如下图:
concurrentHashMap 锁的方式是细粒度的。concurrentHashMap 将 hash 分为 16 个桶(默认值),诸如 get、put、remove 等常用操作只锁住当前需要用到的桶。
concurrentHashMap 的读取并发,因为读取的大多数时候都没有锁定,所以读取操作几乎是完全的并发操作,只是在求 size 时才需要锁定整个 hash。
而且在迭代时,concurrentHashMap 使用了不同于传统集合的快速失败迭代器的另一种迭代方式,弱一致迭代器。在这种方式中,当 iterator 被创建后集合再发生改变就不会抛出 ConcurrentModificationException,取而代之的是在改变时 new 新的数据而不是影响原来的数据,iterator 完成后再讲头指针替代为新的数据,这样 iterator 时使用的是原来的数据。
24、HasmMap 和 HashSet 的区别
(1)先了解一下 HashCode
Java 中的集合有两类,一类是 List,一类是 Set。
List:元素有序,可以重复;
Set:元素无序,不可重复;
要想保证元素的不重复,拿什么来判断呢?这就是 Object.equals 方法了。如果元素有很多,增加一个元素,就要判断 n 次吗?
显然不现实,于是,Java 采用了哈希表的原理。哈希算法也称为散列算法,是将数据依特定算法直接指定到一根地址上,初学者可以简单的理解为,HashCode 方法返回的就是对象存储的物理位置(实际上并不是)。
这样一来,当集合添加新的元素时,先调用这个元素的 hashcode() 方法,就一下子能定位到他应该放置的物理位置上。如果这个位置上没有元素,他就可以直接存储在这个位置上,不用再进行任何比较了。如果这个位置上有元素,就调用它的 equals 方法与新元素进行比较,想同的话就不存了,不相同就散列其它的地址。所以这里存在一个冲突解决的问题。这样一来实际上调用 equals 方法的次数就大大降低了,几乎只需要一两次。
简而言之,在集合查找时,hashcode 能大大降低对象比较次数,提高查找效率。
Java 对象的 equals 方法和 hashCode 方法时这样规定的:
相等的对象就必须具有相等的 hashcode。
- 如果两个对象的 hashcode 相同,他们并不一定相同。
- 如果两个对象的 hashcode 相同,他们并不一定相同。
如果两个 Java 对象 A 和 B,A 和 B 不相等,但是 A 和 B 的哈希码相等,将 A 和 B 都存入 HashMap 时会发生哈希冲突,也就是 A 和 B 存放在 HashMap 内部数组的位置索引相同,这时 HashMap 会在该位置建立一个链接表,将 A 和 B 串起来放在该位置,显然,该情况不违反 HashMap 的使用规则,是允许的。当然,哈希冲突越少越好,尽量采用好的哈希算法避免哈希冲突。
equals() 相等的两个对象,hashcode() 一定相等;equals() 不相等的两个对象,却并不能证明他们的 hashcode() 不相等。
(2)HashMap 和 HashSet 的区别
25、请谈谈 ReadWriteLock 和 StampedLock
ReadWriteLock 包括两种子锁
(1)ReadWriteLock
ReadWriteLock 可以实现多个读锁同时进行,但是读与写和写于写互斥,只能有一个写锁线程在进行。
(2)StampedLock
StampedLock 是 Jdk 在 1.8 提供的一种读写锁,相比较 ReentrantReadWriteLock 性能更好,因为 ReentrantReadWriteLock 在读写之间是互斥的,使用的是一种悲观策略,在读线程特别多的情况下,会造成写线程处于饥饿状态,虽然可以在初始化的时候设置为 true 指定为公平,但是吞吐量又下去了,而 StampedLock 是提供了一种乐观策略,更好的实现读写分离,并且吞吐量不会下降。
StampedLock 包括三种锁:
(1)写锁 writeLock:
writeLock 是一个独占锁写锁,当一个线程获得该锁后,其他请求读锁或者写锁的线程阻塞, 获取成功后,会返回一个 stamp(凭据)变量来表示该锁的版本,在释放锁时调用 unlockWrite 方法传递 stamp 参数。提供了非阻塞式获取锁 tryWriteLock。
(2)悲观读锁 readLock:
readLock 是一个共享读锁,在没有线程获取写锁情况下,多个线程可以获取该锁。如果有写锁获取,那么其他线程请求读锁会被阻塞。悲观读锁会认为其他线程可能要对自己操作的数据进行修改,所以需要先对数据进行加锁,这是在读少写多的情况下考虑的。请求该锁成功后会返回一个 stamp 值,在释放锁时调用 unlockRead 方法传递 stamp 参数。提供了非阻塞式获取锁方法 tryWriteLock。
(3)乐观读锁 tryOptimisticRead:
tryOptimisticRead 相对比悲观读锁,在操作数据前并没有通过 CAS 设置锁的状态,如果没有线程获取写锁,则返回一个非 0 的 stamp 变量,获取该 stamp 后在操作数据前还需要调用 validate 方法来判断期间是否有线程获取了写锁,如果是返回值为 0 则有线程获取写锁,如果不是 0 则可以使用 stamp 变量的锁来操作数据。由于 tryOptimisticRead 并没有修改锁状态,所以不需要释放锁。这是读多写少的情况下考虑的,不涉及 CAS 操作,所以效率较高,在保证数据一致性上需要复制一份要操作的变量到方法栈中,并且在操作数据时可能其他写线程已经修改了数据,而我们操作的是方法栈里面的数据,也就是一个快照,所以最多返回的不是最新的数据,但是一致性得到了保证。
26、线程的 run() 和 start() 有什么区别?
每个线程都是通过某个特定 Thread 对象所对应的方法 run() 来完成其操作的,run() 方法称为线程体。通过调用 Thread 类的 start() 方法来启动一个线程。
start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。
start() 方法来启动一个线程,真正实现了多线程运行。调用 start() 方法无需等待 run 方法体代码执行完毕,可以直接继续执行其他的代码; 此时线程是处于就绪状态,并没有运行。 然后通过此 Thread 类调用方法 run() 来完成其运行状态, run() 方法运行结束, 此线程终止。然后 CPU 再调度其它线程。
run() 方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用 run(),其实就相当于是调用了一个普通函数而已,直接待用 run() 方法必须等待 run() 方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用 start() 方法而不是 run() 方法。
27、为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
这是另一个非常经典的 java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!
new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。
而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。
28、Synchronized 用过吗,其原理是什么?
(1)可重入性
synchronized 的锁对象中有一个计数器(recursions 变量)会记录线程获得几次锁;
- 可重入的好处:
- 可以避免死锁;
- 可以让我们更好的封装代码;
synchronized 是可重入锁,每部锁对象会有一个计数器记录线程获取几次锁,在执行完同步代码块时,计数器的数量会 - 1,直到计数器的数量为 0,就释放这个锁。
(2)不可中断性
- 一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断;
- synchronized 属于不可被中断;
- Lock lock 方法是不可中断的;
- Lock tryLock 方法是可中断的;
29、JVM 对 Java 的原生锁做了哪些优化?
(1)自旋锁
在线程进行阻塞的时候,先让线程自旋等待一段时间,可能这段时间其它线程已经解锁,这时就无需让线程再进行阻塞操作了。
自旋默认次数是 10 次。
(2)自适应自旋锁
自旋锁的升级,自旋的次数不再固定,由前一次自旋次数和锁的拥有者的状态决定。
(3)锁消除
在动态编译同步代码块的时候,JIT 编译器借助逃逸分析技术来判断锁对象是否只被一个线程访问,而没有其他线程,这时就可以取消锁了。
4、锁粗化
当 JIT 编译器发现一系列的操作都对同一个对象反复加锁解锁,甚至加锁操作出现在循环中,此时会将加锁同步的范围粗化到整个操作系列的外部。
锁粒度:不要锁住一些无关的代码。
锁粗化:可以一次性执行完的不要多次加锁执行。
30、为什么 wait(), notify() 和 notifyAll() 必须在同步方法或者同步块中被调用?
Java 中,任何对象都可以作为锁,并且 wait(),notify() 等方法用于等待对象的锁或者唤醒线程,在 Java 的线程中并没有可供任何对象使用的锁,所以任意对象调用方法一定定义在 Object 类中。
wait(), notify() 和 notifyAll() 这些方法在同步代码块中调用
有的人会说,既然是线程放弃对象锁,那也可以把 wait() 定义在 Thread 类里面啊,新定义的线程继承于 Thread 类,也不需要重新定义 wait() 方法的实现。然而,这样做有一个非常大的问题,一个线程完全可以持有很多锁,你一个线程放弃锁的时候,到底要放弃哪个锁?当然了,这种设计并不是不能实现,只是管理起来更加复杂。
综上所述,wait()、notify() 和 notifyAll() 方法要定义在 Object 类中。
31、Java 如何实现多线程之间的通讯和协作?
可以通过中断 和 共享变量的方式实现线程间的通讯和协作
比如说最经典的生产者 - 消费者模型:当队列满时,生产者需要等待队列有空间才能继续往里面放入商品,而在等待的期间内,生产者必须释放对临界资源(即队列)的占用权。因为生产者如果不释放对临界资源的占用权,那么消费者就无法消费队列中的商品,就不会让队列有空间,那么生产者就会一直无限等待下去。因此,一般情况下,当队列满时,会让生产者交出对临界资源的占用权,并进入挂起状态。然后等待消费者消费了商品,然后消费者通知生产者队列有空间了。同样地,当队列空时,消费者也必须等待,等待生产者通知它队列中有商品了。这种互相通信的过程就是线程间的协作。
Java 中线程通信协作的最常见的两种方式:
1、syncrhoized 加锁的线程的 Object 类的 wait()/notify()/notifyAll()
2、ReentrantLock 类加锁的线程的 Condition 类的 await()/signal()/signalAll()
线程间直接的数据交换:
通过管道进行线程间通信:1)字节流;2)字符流
32、Thread 类中的 yield 方法有什么作用?
yield() 应该做的是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。因此,使用 yield() 的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证 yield() 达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。
结论:yield() 从未导致线程转到等待 / 睡眠 / 阻塞状态。在大多数情况下,yield() 将导致线程从运行状态转到可运行状态,但有可能没有效果。
33、为什么说 Synchronized 是非公平锁?
当锁被释放后,任何一个线程都有机会竞争得到锁,这样做的目的是提高效率,但缺点是可能产生线程饥饿现象。
34、请谈谈 volatile 有什么特点,为什么它能保证变量对所有线程的可见性?
volatile 只能作用于变量,保证了操作可见性和有序性,不保证原子性。
在 Java 的内存模型中分为主内存和工作内存,Java 内存模型规定所有的变量存储在主内存中,每条线程都有自己的工作内存。
主内存和工作内存之间的交互分为 8 个原子操作:
- lock
- unlock
- read
- load
- assign
- use
- store
- write
volatile 修饰的变量,只有对 volatile 进行 assign 操作,才可以 load,只有 load 才可以 use,,这样就保证了在工作内存操作 volatile 变量,都会同步到主内存中。
35、为什么说 Synchronized 是一个悲观锁?乐观锁的实现原理又是什么?什么是 CAS,它有什么特性?
Synchronized 的并发策略是悲观的,不管是否产生竞争,任何数据的操作都必须加锁。
乐观锁的核心是 CAS,CAS 包括内存值、预期值、新值,只有当内存值等于预期值时,才会将内存值修改为新值。
36、乐观锁一定就是好的吗?
乐观锁认为对一个对象的操作不会引发冲突,所以每次操作都不进行加锁,只是在最后提交更改时验证是否发生冲突,如果冲突则再试一遍,直至成功为止,这个尝试的过程称为自旋。
乐观锁没有加锁,但乐观锁引入了 ABA 问题,此时一般采用版本号进行控制;
也可能产生自旋次数过多问题,此时并不能提高效率,反而不如直接加锁的效率高;
只能保证一个对象的原子性,可以封装成对象,再进行 CAS 操作;
37、请尽可能详尽地对比下 Synchronized 和 ReentrantLock 的异同。
(1)相似点
它们都是阻塞式的同步,也就是说一个线程获得了对象锁,进入代码块,其它访问该同步块的线程都必须阻塞在同步代码块外面等待,而进行线程阻塞和唤醒的代码是比较高的。
(2)功能区别
Synchronized 是 java 语言的关键字,是原生语法层面的互斥,需要 JVM 实现;ReentrantLock 是 JDK1.5 之后提供的 API 层面的互斥锁,需要 lock 和 unlock() 方法配合 try/finally 代码块来完成。
Synchronized 使用较 ReentrantLock 便利一些;
锁的细粒度和灵活性:ReentrantLock 强于 Synchronized;
(3)性能区别
Synchronized 引入偏向锁,自旋锁之后,两者的性能差不多,在这种情况下,官方建议使用 Synchronized。
① Synchronized
Synchronized 会在同步块的前后分别形成 monitorenter 和 monitorexit 两个字节码指令。
在执行 monitorenter 指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计数器 + 1,相应的执行 monitorexit 时,计数器 - 1,当计数器为 0 时,锁就会被释放。如果获取锁失败,当前线程就要阻塞,知道对象锁被另一个线程释放为止。
② ReentrantLock
ReentrantLock 是 java.util.concurrent 包下提供的一套互斥锁,相比 Synchronized,ReentrantLock 类提供了一些高级功能,主要有如下三项:
等待可中断,持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于 Synchronized 避免出现死锁的情况。通过 lock.lockInterruptibly() 来实现这一机制;
公平锁,多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized 锁是非公平锁;ReentrantLock 默认也是非公平锁,可以通过参数 true 设为公平锁,但公平锁表现的性能不是很好;
锁绑定多个条件,一个 ReentrantLock 对象可以同时绑定多个对象。ReentrantLock 提供了一个 Condition(条件)类,用来实现分组唤醒需要唤醒的线程们,而不是像 Synchronized 要么随机唤醒一个线程,要么唤醒全部线程。
38、ReentrantLock 是如何实现可重入性的?
(1)什么是可重入性
一个线程持有锁时,当其他线程尝试获取该锁时,会被阻塞;而这个线程尝试获取自己持有锁时,如果成功说明该锁是可重入的,反之则不可重入。
(2)synchronized 是如何实现可重入性
synchronized 关键字经过编译后,会在同步块的前后分别形成 monitorenter 和 monitorexit 两个字节码指令。每个锁对象内部维护一个计数器,该计数器初始值为 0,表示任何线程都可以获取该锁并执行相应的方法。根据虚拟机规范要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有了对象的锁,把锁的计数器 + 1,相应的在执行 monitorexit 指令后锁计数器 - 1,当计数器为 0 时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
(3)ReentrantLock 如何实现可重入性
ReentrantLock 使用内部类 Sync 来管理锁,所以真正的获取锁是由 Sync 的实现类控制的。Sync 有两个实现,分别为 NonfairSync(非公公平锁)和 FairSync(公平锁)。Sync 通过继承 AQS 实现,在 AQS 中维护了一个 private volatile int state 来计算重入次数,避免频繁的持有释放操作带来的线程问题。
(4)ReentrantLock 代码实例
// Sync继承于AQS
abstract static class Sync extends AbstractQueuedSynchronizer {
...
}
// ReentrantLock默认是非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
// 可以通过向构造方法中传true来实现公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
protected final boolean tryAcquire(int acquires) {
// 当前想要获取锁的线程
final Thread current = Thread.currentThread();
// 当前锁的状态
int c = getState();
// state == 0 此时此刻没有线程持有锁
if (c == 0) {
// 虽然此时此刻锁是可以用的,但是这是公平锁,既然是公平,就得讲究先来后到,
// 看看有没有别人在队列中等了半天了
if (!hasQueuedPredecessors() &&
// 如果没有线程在等待,那就用CAS尝试一下,成功了就获取到锁了,
// 不成功的话,只能说明一个问题,就在刚刚几乎同一时刻有个线程抢先了 =_=
// 因为刚刚还没人的,我判断过了
compareAndSetState(0, acquires)) {
// 到这里就是获取到锁了,标记一下,告诉大家,现在是我占用了锁
setExclusiveOwnerThread(current);
return true;
}
}
// 会进入这个else if分支,说明是重入了,需要操作:state=state+1
// 这里不存在并发问题
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 如果到这里,说明前面的if和else if都没有返回true,说明没有获取到锁
return false;
}
(5)代码分析
当一个线程在获取锁过程中,先判断 state 的值是否为 0,如果是表示没有线程持有锁,就可以尝试获取锁。
当 state 的值不为 0 时,表示锁已经被一个线程占用了,这时会做一个判断 current==getExclusiveOwnerThread(),这个方法返回的是当前持有锁的线程,这个判断是看当前持有锁的线程是不是自己,如果是自己,那么将 state 的值 + 1,表示重入返回即可。
39、什么是锁消除和锁粗化?
(1)锁消除
所消除就是虚拟机根据一个对象是否真正存在同步情况,若不存在同步情况,则对该对象的访问无需经过加锁解锁的操作。
比如 StringBuffer 的 append 方法,因为 append 方法需要判断对象是否被占用,而如果代码不存在锁竞争,那么这部分的性能消耗是无意义的。于是虚拟机在即时编译的时候就会将上面的代码进行优化,也就是锁消除。
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
从源码可以看出,append 方法用了 synchronized 关键字,它是线程安全的。但我们可能仅在线程内部把 StringBuffer 当做局部变量使用;StringBuffer 仅在方法内作用域有效,不存在线程安全的问题,这时我们可以通过编译器将其优化,将锁消除,前提是 Java 必须运行在 server 模式,同时必须开启逃逸分析;
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
其中+DoEscapeAnalysis表示开启逃逸分析,+EliminateLocks表示锁消除。
public static String createStringBuffer(String str1, String str2) {
StringBuffer sBuf = new StringBuffer();
sBuf.append(str1);// append方法是同步操作
sBuf.append(str2);
return sBuf.toString();
}
逃逸分析:比如上面的代码,它要看 sBuf 是否可能逃出它的作用域?如果将 sBuf 作为方法的返回值进行返回,那么它在方法外部可能被当作一个全局对象使用,就有可能发生线程安全问题,这时就可以说 sBuf 这个对象发生逃逸了,因而不应将 append 操作的锁消除,但我们上面的代码没有发生锁逃逸,锁消除就可以带来一定的性能提升。
(2)锁粗化
锁的请求、同步、释放都会消耗一定的系统资源,如果高频的锁请求反而不利于系统性能的优化,锁粗化就是把多次的锁请求合并成一个请求,扩大锁的范围,降低锁请求、同步、释放带来的性能损耗。
40、跟 Synchronized 相比,可重入锁 ReentrantLock 其实现原理有什么不同?
(1)都是可重入锁;
(2)ReentrantLock 内部是实现了 Sync,Sync 继承于 AQS 抽象类。Sync 有两个实现,一个是公平锁,一个是非公平锁,通过构造函数定义。AQS 中维护了一个 state 来计算重入次数,避免频繁的持有释放操作带来的线程问题。
(3)ReentrantLock 只能定义代码块,而 Synchronized 可以定义方法和代码块;
4、Synchronized 是 JVM 的一个内部关键字,ReentrantLock 是 JDK1.5 之后引入的一个 API 层面的互斥锁;
5、Synchronized 实现自动的加锁、释放锁,ReentrantLock 需要手动加锁和释放锁,中间可以暂停;
6、Synchronized 由于引进了偏向锁和自旋锁,所以性能上和 ReentrantLock 差不多,但操作上方便很多,所以优先使用 Synchronized。