赞
踩
(1)重载是多态的集中体现,在类中,要以统一的方式处理不同类型数据的时候,可以用重载。
(2)重写的使用是建立在继承关系上的,子类在继承父类的基础上,增加新的功能,可以用重写。
(3)简单总结:
重载是多样性,重写是增强剂;
目的是提高程序的多样性和健壮性,以适配不同场景使用时,使用重载进行扩展;
目的是在不修改原方法及源代码的基础上对方法进行扩展或增强时,使用重写;
生活例子:
你想吃一碗面,我给你提供了拉面,炒面,刀削面,担担面供你选择,这是重载;
你想吃一碗面,我不但给你端来了面,还给你加了青菜,加了鸡蛋,这个是重写;
设计模式:
cglib 实现动态代理,核心原理用的就是方法的重写;
详细解答:
Java 的重载 (overload) 最重要的应用场景就是构造器的重载,构造器重载后,提供多种形参形式的构造器,可以应对不同的业务需求,加强程序的健壮性和可扩展性,比如我们最近学习的 Spring 源码中的 ClassPathXmlApplicationContext,它的构造函数使用重载一共提供了 10 个构造函数,这样就为业务的选择提供了多选择性。在应用到方法中时,主要是为了增强方法的健壮性和可扩展性,比如我们在开发中常用的各种工具类,比如我目前工作中的短信工具类 SMSUtil, 发短信的方法就会使用重载,针对不同业务场景下的不同形参,提供短信发送方法,这样提高了工具类的扩展性和健壮性。
总结:重载必须要修改方法 (构造器) 的形参列表,可以修改方法的返回值类型,也可以修改方法的异常信息即访问权限;使用范围是在同一个类中,目的是对方法 (构造器) 进行功能扩展,以应对多业务场景的不同使用需求。提高程序的健壮性和扩展性。
java 的重写 (override) 只要用于子类对父类方法的扩展或修改,但是在我们开发中,为了避免程序混乱,重写一般都是为了方法的扩展,比如在 cglib 方式实现的动态代理中,代理类就是继承了目标类,对目标类的方法进行重写,同时在方法前后进行切面织入。
总结:方法重写时,参数列表,返回值得类型是一定不能修改的,异常可以减少或者删除,但是不能抛出新的异常或者更广的异常,方法的访问权限可以降低限制,但是不能做更严格的限制。
(4)在里氏替换原则中,子类对父类的方法尽量不要重写和重载。(我们可以采用 final 的手段强制来遵循)
接口和抽象类都遵循” 面向接口而不是实现编码” 设计原则,它可以增加代码的灵活性,可以适应不断变化的需求。下面有几个点可以帮助你回答这个问题:在 Java 中,你只能继承一个类,但可以实现多个接口。所以一旦你继承了一个类,你就失去了继承其他类的机会了。
接口通常被用来表示附属描述或行为如: Runnable 、 Clonable 、 Serializable 等等,因此当你使用抽象类来表示行为时,你的类就不能同时是 Runnable 和 Clonable(注:这里的意思是指如果把 Runnable 等实现为抽象类的情况) ,因为在 Java 中你不能继承两个类,但当你使用接口时,你的类就可以同时拥有多个不同的行为。
在一些对时间要求比较高的应用中,倾向于使用抽象类,它会比接口稍快一点。如果希望把一系列行为都规范在类继承层次内,并且可以更好地在同一个地方进行编码,那么抽象类是一个更好的选择。有时,接口和抽象类可以一起使用,接口中定义函数,而在抽象类中定义默认的实现。
- //用 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();
byte 的范围是 - 128~127。
字节长度为 8 位,最左边的是符号位,而 127 的二进制为 01111111,所以执行 + 1 操作时,01111111 变为 10000000。
大家知道,计算机中存储负数,存的是补码的兴衰。左边第一位为符号位。
那么负数的补码转换成十进制如下:
一个数如果为正,则它的原码、反码、补码相同;一个正数的补码,将其转化为十进制,可以直接转换。
已知一个负数的补码,将其转换为十进制数,步骤如下:
例如 10000000,最高位是 1,是负数,①对各位取反得 01111111,转换为十进制就是 127,加上负号得 - 127,再减去 1 得 - 128;
(1)Collection
① set
HashSet、TreeSet
② list
ArrayList、LinkedList、Vector
(2)Map
HashMap、HashTable、TreeMap
(1)Collection 是最基本的集合接口,Collection 派生了两个子接口 list 和 set,分别定义了两种不同的存储方式。
(2)Collections 是一个包装类,它包含各种有关集合操作的静态方法(对集合的搜索、排序、线程安全化等)。
此类不能实例化,就像一个工具类,服务于 Collection 框架。
(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 可以动态增长,查找元素效率高,插入删除元素效率低,因为会引起其他元素位置改变。
(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)时,将链表转换为红黑树。在性能上进一步得到提升。
(1)HashSet
HashSet 是 set 接口的实现类,set 下面最主要的实现类就是 HashSet(也就是用的最多的),此外还有 LinkedHashSet 和 TreeSet。
HashSet 是无序的、不可重复的。通过对象的 hashCode 和 equals 方法保证对象的唯一性。
HashSet 内部的存储结构是哈希表,是线程不安全的。
(2)TreeSet
TreeSet 对元素进行排序的方式:
元素自身具备比较功能,需要实现 Comparable 接口,并覆盖 compareTo 方法。
元素自身不具备比较功能,需要实现 Comparator 接口,并覆盖 compare 方法。
(3)LinkedHashSet
LinkedHashSet 是一种有序的 Set 集合,即其元素的存入和输出的顺序是相同的。
HashSet 实际上是一个 HashMap 实例,数据存储结构都是数组 + 链表。
HashSet 是基于 HashMap 实现的,HashSet 中的元素都存放在 HashMap 的 key 上面,而 value 都是一个统一的对象 PRESENT。
- private static final Object PRESENT = new Object();
-
HashSet 中 add 方法调用的是底层 HashMap 中的 put 方法,put 方法要判断插入值是否存在,而 HashSet 的 add 方法,首先判断元素是否存在,如果存在则插入,如果不存在则不插入,这样就保证了 HashSet 中不存在重复值。
通过对象的 hashCode 和 equals 方法保证对象的唯一性。
ArrayList 是动态数组的数据结构实现,查找和遍历的效率较高;
LinkedList 是双向链表的数据结构,增加和删除的效率较高;
- 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]);
- }
(1)offer() 和 add() 区别:
增加新项时,如果队列满了,add 会抛出异常,offer 返回 false。
(2)poll() 和 remove() 区别:
poll() 和 remove() 都是从队列中删除第一个元素,remove 抛出异常,poll 返回 null。
(3)peek() 和 element()区别:
peek() 和 element()用于查询队列头部元素,为空时 element 抛出异常,peek 返回 null。
Vector:就比 Arraylist 多了个同步化机制(线程安全)。
Stack:栈,也是线程安全的,继承于 Vector。
Hashtable:就比 Hashmap 多了个线程安全。
ConcurrentHashMap: 是一种高效但是线程安全的集合。
为了方便的处理集合中的元素, Java 中出现了一个对象, 该对象提供了一些方法专门处理集合中的元素. 例如删除和获取集合中的元素. 该对象就叫做迭代器 (Iterator)。
Iterator 接口源码中的方法:
(1)ListIterator 继承 Iterator
(2)ListIterator 比 Iterator 多方法
(3)使用范围不同,Iterator 可以迭代所有集合;ListIterator 只能用于 List 及其子类
我们很容易想到用 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)
(1)队列先进先出,栈先进后出。
(2)遍历数据速度不同。
栈只能从头部取数据 也就最先放入的需要遍历整个栈最后才能取出来,而且在遍历数据的时候还得为数据开辟临时空间,保持数据在遍历前的一致性;
队列则不同,他基于地址指针进行遍历,而且可以从头或尾部开始遍历,但不能同时遍历,无需开辟临时空间,因为在遍历的过程中不影像数据结构,速度要快的多。
ConcurrentHashMap 的原理是引用了内部的 Segment (ReentrantLock) 分段锁,保证在操作不同段 map 的时候, 可以并发执行, 操作同段 map 的时候,进行锁的竞争和等待。从而达到线程安全, 且效率大于 synchronized。
但是在 Java 8 之后, JDK 却弃用了这个策略,重新使用了 synchronized+CAS。
弃用原因
通过 JDK 的源码和官方文档看来, 他们认为的弃用分段锁的原因由以下几点:
加入多个分段锁浪费内存空间。
生产环境中, map 在放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的长时间等待。
为了提高 GC 的效率
新的同步方案
既然弃用了分段锁, 那么一定由新的线程安全方案, 我们来看看源码是怎么解决线程安全的呢?(源码保留了 segment 代码, 但并没有使用)。
我想从下面几个角度讨论这个问题:
(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 版本的升级而不改动代码的前提下获得性能上的提升。
concurrentHashMap 融合了 hashmap 和 hashtable 的优势,hashmap 是不同步的,但是单线程情况下效率高,hashtable 是同步的同步情况下保证程序执行的正确性。
但 hashtable 每次同步执行的时候都要锁住整个结构,如下图:
concurrentHashMap 锁的方式是细粒度的。concurrentHashMap 将 hash 分为 16 个桶(默认值),诸如 get、put、remove 等常用操作只锁住当前需要用到的桶。
concurrentHashMap 的读取并发,因为读取的大多数时候都没有锁定,所以读取操作几乎是完全的并发操作,只是在求 size 时才需要锁定整个 hash。
而且在迭代时,concurrentHashMap 使用了不同于传统集合的快速失败迭代器的另一种迭代方式,弱一致迭代器。在这种方式中,当 iterator 被创建后集合再发生改变就不会抛出 ConcurrentModificationException,取而代之的是在改变时 new 新的数据而不是影响原来的数据,iterator 完成后再讲头指针替代为新的数据,这样 iterator 时使用的是原来的数据。
(1)先了解一下 HashCode
Java 中的集合有两类,一类是 List,一类是 Set。
List:元素有序,可以重复;
Set:元素无序,不可重复;
要想保证元素的不重复,拿什么来判断呢?这就是 Object.equals 方法了。如果元素有很多,增加一个元素,就要判断 n 次吗?
显然不现实,于是,Java 采用了哈希表的原理。哈希算法也称为散列算法,是将数据依特定算法直接指定到一根地址上,初学者可以简单的理解为,HashCode 方法返回的就是对象存储的物理位置(实际上并不是)。
这样一来,当集合添加新的元素时,先调用这个元素的 hashcode() 方法,就一下子能定位到他应该放置的物理位置上。如果这个位置上没有元素,他就可以直接存储在这个位置上,不用再进行任何比较了。如果这个位置上有元素,就调用它的 equals 方法与新元素进行比较,想同的话就不存了,不相同就散列其它的地址。所以这里存在一个冲突解决的问题。这样一来实际上调用 equals 方法的次数就大大降低了,几乎只需要一两次。
简而言之,在集合查找时,hashcode 能大大降低对象比较次数,提高查找效率。
Java 对象的 equals 方法和 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 的区别
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 操作,所以效率较高,在保证数据一致性上需要复制一份要操作的变量到方法栈中,并且在操作数据时可能其他写线程已经修改了数据,而我们操作的是方法栈里面的数据,也就是一个快照,所以最多返回的不是最新的数据,但是一致性得到了保证。
每个线程都是通过某个特定 Thread 对象所对应的方法 run() 来完成其操作的,run() 方法称为线程体。通过调用 Thread 类的 start() 方法来启动一个线程。
start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。
start() 方法来启动一个线程,真正实现了多线程运行。调用 start() 方法无需等待 run 方法体代码执行完毕,可以直接继续执行其他的代码; 此时线程是处于就绪状态,并没有运行。 然后通过此 Thread 类调用方法 run() 来完成其运行状态, run() 方法运行结束, 此线程终止。然后 CPU 再调度其它线程。
run() 方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用 run(),其实就相当于是调用了一个普通函数而已,直接待用 run() 方法必须等待 run() 方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用 start() 方法而不是 run() 方法。
这是另一个非常经典的 java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!
new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。
而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。
(1)可重入性
synchronized 的锁对象中有一个计数器(recursions 变量)会记录线程获得几次锁;
synchronized 是可重入锁,每部锁对象会有一个计数器记录线程获取几次锁,在执行完同步代码块时,计数器的数量会 - 1,直到计数器的数量为 0,就释放这个锁。
(2)不可中断性
(1)自旋锁
在线程进行阻塞的时候,先让线程自旋等待一段时间,可能这段时间其它线程已经解锁,这时就无需让线程再进行阻塞操作了。
自旋默认次数是 10 次。
(2)自适应自旋锁
自旋锁的升级,自旋的次数不再固定,由前一次自旋次数和锁的拥有者的状态决定。
(3)锁消除
在动态编译同步代码块的时候,JIT 编译器借助逃逸分析技术来判断锁对象是否只被一个线程访问,而没有其他线程,这时就可以取消锁了。
4、锁粗化
当 JIT 编译器发现一系列的操作都对同一个对象反复加锁解锁,甚至加锁操作出现在循环中,此时会将加锁同步的范围粗化到整个操作系列的外部。
锁粒度:不要锁住一些无关的代码。
锁粗化:可以一次性执行完的不要多次加锁执行。
Java 中,任何对象都可以作为锁,并且 wait(),notify() 等方法用于等待对象的锁或者唤醒线程,在 Java 的线程中并没有可供任何对象使用的锁,所以任意对象调用方法一定定义在 Object 类中。
wait(), notify() 和 notifyAll() 这些方法在同步代码块中调用
有的人会说,既然是线程放弃对象锁,那也可以把 wait() 定义在 Thread 类里面啊,新定义的线程继承于 Thread 类,也不需要重新定义 wait() 方法的实现。然而,这样做有一个非常大的问题,一个线程完全可以持有很多锁,你一个线程放弃锁的时候,到底要放弃哪个锁?当然了,这种设计并不是不能实现,只是管理起来更加复杂。
综上所述,wait()、notify() 和 notifyAll() 方法要定义在 Object 类中。
可以通过中断 和 共享变量的方式实现线程间的通讯和协作
比如说最经典的生产者 - 消费者模型:当队列满时,生产者需要等待队列有空间才能继续往里面放入商品,而在等待的期间内,生产者必须释放对临界资源(即队列)的占用权。因为生产者如果不释放对临界资源的占用权,那么消费者就无法消费队列中的商品,就不会让队列有空间,那么生产者就会一直无限等待下去。因此,一般情况下,当队列满时,会让生产者交出对临界资源的占用权,并进入挂起状态。然后等待消费者消费了商品,然后消费者通知生产者队列有空间了。同样地,当队列空时,消费者也必须等待,等待生产者通知它队列中有商品了。这种互相通信的过程就是线程间的协作。
Java 中线程通信协作的最常见的两种方式:
1、syncrhoized 加锁的线程的 Object 类的 wait()/notify()/notifyAll()
2、ReentrantLock 类加锁的线程的 Condition 类的 await()/signal()/signalAll()
线程间直接的数据交换:
通过管道进行线程间通信:1)字节流;2)字符流
yield() 应该做的是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。因此,使用 yield() 的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证 yield() 达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。
结论:yield() 从未导致线程转到等待 / 睡眠 / 阻塞状态。在大多数情况下,yield() 将导致线程从运行状态转到可运行状态,但有可能没有效果。
当锁被释放后,任何一个线程都有机会竞争得到锁,这样做的目的是提高效率,但缺点是可能产生线程饥饿现象。
volatile 只能作用于变量,保证了操作可见性和有序性,不保证原子性。
在 Java 的内存模型中分为主内存和工作内存,Java 内存模型规定所有的变量存储在主内存中,每条线程都有自己的工作内存。
主内存和工作内存之间的交互分为 8 个原子操作:
volatile 修饰的变量,只有对 volatile 进行 assign 操作,才可以 load,只有 load 才可以 use,,这样就保证了在工作内存操作 volatile 变量,都会同步到主内存中。
Synchronized 的并发策略是悲观的,不管是否产生竞争,任何数据的操作都必须加锁。
乐观锁的核心是 CAS,CAS 包括内存值、预期值、新值,只有当内存值等于预期值时,才会将内存值修改为新值。
乐观锁认为对一个对象的操作不会引发冲突,所以每次操作都不进行加锁,只是在最后提交更改时验证是否发生冲突,如果冲突则再试一遍,直至成功为止,这个尝试的过程称为自旋。
乐观锁没有加锁,但乐观锁引入了 ABA 问题,此时一般采用版本号进行控制;
也可能产生自旋次数过多问题,此时并不能提高效率,反而不如直接加锁的效率高;
只能保证一个对象的原子性,可以封装成对象,再进行 CAS 操作;
(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 要么随机唤醒一个线程,要么唤醒全部线程。
(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,表示重入返回即可。
(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)锁粗化
锁的请求、同步、释放都会消耗一定的系统资源,如果高频的锁请求反而不利于系统性能的优化,锁粗化就是把多次的锁请求合并成一个请求,扩大锁的范围,降低锁请求、同步、释放带来的性能损耗。
(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。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。