JavaSE学习总结(十三)Set集合HashSet集合LinkedHashSet集合TreeSet集合比较器的使用利用Set集合实现去重
Posted 理想条件
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JavaSE学习总结(十三)Set集合HashSet集合LinkedHashSet集合TreeSet集合比较器的使用利用Set集合实现去重相关的知识,希望对你有一定的参考价值。
JavaSE学习总结(十三)Set集合/HashSet集合/LinkedHashSet集合/TreeSet集合/比较器的使用/利用Set集合实现去重
一、Set集合
Set集合是Collection集合的一个子接口,实际上Set就是Collection,只是行为略有不同:
- Set集合不保存重复的元素。(唯一性)
- Set集合是无序的:存储和取出的顺序无序。(无序性)
案例演示
import java.util.HashSet;
import java.util.Set;
public class MyTest
public static void main(String[] args)
Set<String> set = new HashSet<>();
set.add("hello");
set.add("world");
set.add("hello");
set.add("world");
set.add("good");
set.add("morning");
for (String s : set)
System.out.println(s);
二、HashSet集合
(一)概述
HashSet是Set接口的典型实现(集合中元素也是无序且唯一的),实现了Set接口中的所有方法,并没有添加额外的方法,大多数时候使用Set集合时就是使用这个实现类。HashSet 底层数据结构是哈希表,HashSet 不是线程安全的,集合元素可以是 null。
- 哈希表:是一个元素为链表的数组,综合了数组和链表的优点 (像新华字典一样) (JDK1.7之前)
(二)存储规则
当向 HashSet 集合中添加一个元素A时,HashSet 会调用该对象的 hashCode() 方法来得到该对象的整型hashCode 值,然后根据 hashCode 值决定该对象在 HashSet 中的存储位置,如果存储位置已经有元素B了,然后再根据equals()方法来判断元素A和元素B是否相同,相同则不再存入这个元素A,以确保元素唯一性;不同则将元素A链在已存在的元素B后面。
结论:HashSet 保证元素唯一性是靠元素重写hashCode()和equals()方法来保证的,如果不重写则无法保证。
(三)HashSet中如何判断集合元素相等
两个对象比较 具体分为如下四个情况:
- 1.如果有两个元素的hashCode()方法返回不相等的值,但它们通过equals()方法比较返回false,HashSet将会把它们存储在不同的位置。
- 2.如果有两个元素的hashCode()方法返回不相等的值,但它们通过equals()方法比较返回true,HashSet将会把它们存储在不同的位置。
- 3.如果有两个元素的hashCode()方法返回相等的值,但它们通过equals()方法比较不相等,HashSet将会把它们存储在相同的位置,在这个位置以链表式结构来保存多个对象
- 4.如果有两个元素的hashCode()方法返回相等的值,但它们通过equals()方法比较返回true,HashSet将不予添加。
结论:HashSet判断两个元素相等的标准———两个对象通过hashCode()方法比较相等,并且两个对象的equals()方法返回值也相等。
注意:HashSet是根据元素的hashCode值来快速定位的,如果HashSet中两个以上的元素具有相同的hashCode值,将会导致碰撞次数增加,性能下降。所以如果重写类的equals()方法和hashCode()方法时,应尽量保证两个对象通过hashCode()方法返回值相等时,通过equals()方法比较返回true。
案例演示
用HashSet集合存储学生对象,重写hashCode()和equals()保证集合中的元素不重复。
import java.util.Objects;
import java.util.HashSet;
class Student
private String name;
private int age;
public Student()
public Student(String name, int age)
this.name = name;
this.age = age;
public String getName()
return name;
public void setName(String name)
this.name = name;
public int getAge()
return age;
public void setAge(int age)
this.age = age;
@Override
public String toString()
return "Student" +
"name='" + name + '\\'' +
", age=" + age +
'';
//重写equals()
@Override
public boolean equals(Object o)
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return age == student.age &&
Objects.equals(name, student.name);
//重写hashCode()
@Override
public int hashCode()
return Objects.hash(name, age);
public class MyTest
public static void main(String[] args)
//HashSet集合能够保证元素的唯一性,是靠元素重写hashCode()方法和equals()方法来保证的,如果元素不重写则无法保证
//HashSet 底层用的是HashMap来存的
Student s1 = new Student("张三", 25);
Student s2 = new Student("张三", 25);
Student s3 = new Student("张三", 25);
Student s4 = new Student("王五", 26);
Student s5 = new Student("王五", 23);
Student s6 = new Student("小明", 25);
Student s7 = new Student("赵四", 26);
Student s8 = new Student("李四", 28);
Student s9 = new Student("李四", 28);
Student s10 = new Student("小红", 25);
HashSet<Student> hashSet = new HashSet<>();
hashSet.add(s1);
hashSet.add(s2);
hashSet.add(s3);
hashSet.add(s4);
hashSet.add(s5);
hashSet.add(s6);
hashSet.add(s7);
hashSet.add(s8);
hashSet.add(s9);
hashSet.add(s10);
for (Student student : hashSet)
System.out.println(student);
三、LinkedHashSet集合
LinkedHashSet 底层数据结构是链表和哈希表,元素有序(存取顺序一致)且唯一 ,链表保证了元素有序,哈希表保证了元素唯一。
案例演示
import java.util.LinkedHashSet;
public class MyTest
public static void main(String[] args)
LinkedHashSet<String> linkedHashSet = new LinkedHashSet<>();
linkedHashSet.add("A");
linkedHashSet.add("B");
linkedHashSet.add("D");
linkedHashSet.add("E");
linkedHashSet.add("C");
linkedHashSet.add("E");
linkedHashSet.add("C");
linkedHashSet.add("E");
linkedHashSet.add("C");
for (String s : linkedHashSet)
System.out.println(s);
四、TreeSet集合
(一)特点
元素唯一,并且可以对元素进行排序
底层数据结构是二叉树
案例演示
import java.util.TreeSet;
public class MyTest1
public static void main(String[] args)
TreeSet<Integer> treeSet = new TreeSet<>();
treeSet.add(20);
treeSet.add(18);
treeSet.add(23);
treeSet.add(22);
treeSet.add(17);
treeSet.add(24);
treeSet.add(19);
treeSet.add(18);
treeSet.add(24);
for (Integer integer : treeSet)
System.out.println(integer);
结果是排好序的,并且唯一
那么它的底层的数据结构是如何实现排序的呢?
添加完以后,按左中右的顺序取出来就是有序的了:
(二)排序
Treeset集合的排序又分为自然排序和使用比较器排序
具体用哪种排序,根据你使用的构造方法,如果用空参构造,那么就使用的是自然排序,如果用有参构造,就是使用比较器来排序。
1.自然排序
如果我们使用的是自然排序:那么对元素所属的类是有要求的,要求该类必须实现一个Comparable
接口并且重写里面的compareTo()
方法(否则报错),根据此方法的返回值(正、负、0)来决定元素在二叉树的位置。
我们上面的例子中的Integer类就已经实现了Comparable
接口,并且重写了compareTo()
方法:
继续点开compare()
方法可以看到它的实现逻辑:
其实就是x小于y就返回负数,那么x就放置在y的左边;x等于y返回0,就不放进去;x大于y就返回正数,那么x就放置在y的右边。
案例演示1
需求:将集合中的学生按年龄来排序
import java.util.TreeSet;
class Student implements Comparable<Student>//实现Comparable接口
private String name;
private int age;
public Student()
public Student(String name, int age)
this.name = name;
this.age = age;
public String getName()
return name;
public void setName(String name)
this.name = name;
public int getAge()
return age;
public void setAge(int age)
this.age = age;
@Override
public String toString()
return "Student" +
"name='" + name + '\\'' +
", age=" + age +
'';
@Override
public int compareTo(Student s)
int num1 = this.age-s.age;//比较逻辑是按照年龄大小来排序
//此时可能有人觉得return num1;这个方法就可以结束了
//但是会出现一个现象:名字不同,年龄相同的Student对象存不进去
//而这个和重写equals方法没关系,因为底层数据结构是二叉树不是哈希表
//年龄相同不能说明他是同一个对象,还得比较姓名
int num2=num1==0?this.name.compareTo(s.name):num1;
return num2;//如果需要降序,则return -num2;
public class MyTest
public static void main(String[] args)
Student s1 = new Student("王五", 21);
Student s2 = new Student("王五", 21);
Student s3 = new Student("王五五", 21);
Student s4 = new Student("王五", 22);
Student s5 = new Student("张三", 25);
Student s6 = new Student("李四", 29);
Student s7 = new Student("赵四", 24);
Student s8 = new Student("赵六", 26);
Student s9 = new Student("小明", 26);
Student s10 = new Student("小红", 28);
Student s11 = new Student("小张", 21);
Student s12 = new Student("小刚", 20);
TreeSet<Student> treeSet = new TreeSet<>();
treeSet.add(s1);
treeSet.add(s2);
treeSet.add(s3);
treeSet.add(s4);
treeSet.add(s5);
treeSet.add(s6);
treeSet.add(s7);
treeSet.add(s8);
treeSet.add(s9);
treeSet.add(s10);
treeSet.add(s11);
treeSet.add(s12);
for (Student student : treeSet)
System.out.println(student);
元素的唯一性是靠compareTo()方法的返回值来保证的,如果返回0,表示两个元素相等,则不重复存储
案例演示2
需求:按照姓名的长度进行排序
主要条件是姓名的长度
然后是姓名内容
然后是年龄
import java.util.TreeSet;
class Student implements Comparable<Student>//实现Comparable接口
private String name;
private int age;
public Student()
public Student(String name, int age)
this.name = name;
this.age = age;
public String getName()
return name;
public void setName(String name)
this.name = name;
public int getAge()
return age;
public void setAge(int age)
this.age = age;
@Override
public String toString()
return "Student" +
"name='" + name + '\\'' +
", age=" + age +
'';
@Override
public int compareTo(Student s)
//先比较姓名长度
int num1=this.name.length()-s.name.length();
//姓名长度一样 再比较姓名内容
int num2=num1==0?this.name.compareTo(s.name):num1;
//如果姓名长度和内容一样再比较年龄
int num3=num2==0?this.age-s.age:num2;
return num3;
public class MyTest
public static void main(String[] args)
Student s1 = new Student("王五", 21);
Student s2 = new Student("王五", 21);
Student s3 = new Student("王五五", 21);
Student s4 = new Student("王五六七八", 22);
Student s5 = new Student("张三三", 25);
Student s6 = new Student("李四", 29);
Student s7 = new Student("赵六六", 27);
Student s8 = new Student("赵六六", 26
文章目录
集合
集合体系
Collection
List: 可以重复,有序的
ArrayList:
底层数组实现,查询快,(数组中间)增删慢, 浪费空间
第一次添加时,初始化一个默认容量为10
创建时,通过构造方法指定初始容量
扩容: 添加满了之后,会发生扩容 grow() 扩容为原来的1.5倍
如何保证ArrayList线程安全
- synchronizedList 底层相当于把集合的set add remove加上synchronize锁
- 使用 CopyOnWriteArrayList其线程安全是通过加可重入锁ReentrantLock来保证的(适合读多写少)
- 自定义
LinkedList:
底层是双向链表, 查询慢,增删快 实现队列,栈
什么时候用ArrayList,LinkedList 查找,删除
Vector: 底层是数组 ,线程安全的.若未指定扩容值,扩容2倍
集合遍历
-
for 支持增删(注意索引变化) 增强for(只能删除一个,报错 使用break)
-
迭代器(里面有一个计数器)
iterator();从前向后
listIterator(arrayList.size()); 从指定的位置开始遍历
从后向前listIterator.hasPrevious() previous()
-
stream 流式
Set 不能重复
Set和Map的关系:
二者都不保存重复的元素,存储一组唯一的对象
HashSet 底层封装了HashMap, TreeSet底层封装了TreeMap
为什么重写了equals()方法还需要重写hashCode()方法?
public boolean equals(Object obj)
return (this == obj);
equals()方法比较基本数据类型比较的是值,如果是对象的话,会判断对象属性是否相同,hashcode判断地址是否相同,判断两个对象相等必须满足 地址+属性
如果只重写equals()可能会出现两个没有关系的对象equals相同,但hashcode不相同的情况.因为调用的是object的hashcode,默认的hashcode方法根据对象的内存地址计算得来,所以两个对象不相同,hashcode值也不相等
但是根据hashcode规则,两个对象相等那么hashcode值也一定相等,有矛盾.
哈希冲突的几种解决方案,各个优缺点?
-
开放定址法
这种方法也称再散列法,其基本思想是:当关键字 key 的哈希地址 p=H(key)出现冲突时,以 p 为基础,产生另一个哈希地址 p1,如果 p1 仍然冲突,再以 p 为基础,产生另一个哈希地址 p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。
-
再哈希法(再散列法)
这种方法是同时构造多个不同的哈希函数:
Hi=RH1(key) i=1,2,…,k
当哈希地址Hi=RH1(key)发生冲突时,再计算 Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。
-
链地址法(拉链法)
这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。
HashMap 解决 hash 冲突就采用的这种方式!
HashMap结构
哈希表+链表+红黑树
使用红黑树是为了提升查找数据的速度,插入数据会进行左旋右旋保持平衡
创HashMap是默认哈希表容量是16 也可以指定长度,允许键值为空
添加时首先是通过k的哈希值,再通过哈希函数计算位置,
位置上如果没有元素添加在链表的头结点,如果有插入到链表的下一个节点.
链表的长度>=8,转为红黑树
JDK1.8 前HashMap采用头插法,效率高于尾插发,不需要遍历一遍进行数据插入
JDK1.8后采用尾插法,是为了判断链表的查毒是否大于8
解决哈希冲突采用:链表发 开放定址法 再哈希法 建立公共溢出区
扩展 负载因子 默认为0.75 (为什么)
1 效率低
0.5 浪费空间
扩容为原来的2倍(为什么)
效率高
减少哈希重冲突
HashMap闭环
原因是线程一被挂起时是仍然保留原链表的当前元素e和下一个元素next,而线程二扩容完成以后由于采用头插法已经将当前元素e和下一个元素next位置进行互换,这样导致线程一在扩容过程中会将当前元素e进行两次处理。(第一次是线程被唤起时处理,第二次是采用线程二互换的关系重新定位到e),结果导致出现链表闭环。
ConcurrentHashMap1.8
Node 数组 + 链表 / 红黑树。当冲突链表达到一定长度时,链表会转换成红黑树。
ConcurrentHashMap 内部通过加锁(自旋锁 + CAS + synchronized + 分段锁)来保证线程安全。
通过自旋和CAS操作完成
初始化 Segment 流程:
- 检查 Segment 是否为null.
- 为 null 继续初始化,使用 Segment[0] 的容量和负载因子创建一个 HashEntry 数组。
- 再次检查计算得到的指定位置的 Segment 是否为null.
- 使用创建的 HashEntry 数组初始化这个 Segment.
- 自旋判断计算得到的指定位置的 Segment 是否为null,使用 CAS 在这个位置赋值为 Segment.
put
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
- 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的
hashcode == MOVED == -1
,则需要进行扩容。 - 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于
TREEIFY_THRESHOLD
则要转换为红黑树。
get
- 根据 hash 值计算位置。
- 查找到指定位置,如果头节点就是要找的,直接返回它的 value.
- 如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找之。
- 如果是链表,遍历查找之。
扩容rehash
ConcurrentHashMap 的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。
描述一下ConcurrentHashMap中的hash寻址算法
- 首先是通过 Node 节点的 Key 获取到它的 HashCode 值,再将 HashCode 值通过
spread(int h)
方法进行绕道运算,进而得到最终的 Hash 值。 - 获取到最终的 hash 值后,再通过寻址公式:
index = (tab.length -1) & hash
获得桶位下标。
HashTable结构
数组+链表 键值不能为空 线程安全 使用synchronized锁住
默认11 扩容*2+1
采用奇数导致hash冲突少
TreeSet TreeMap 底层是红黑树结构 根据内容的自然顺序排序
泛型
为了参数化类型,或者说可以将类型当作参数传递给一个类或者是方法。
- 它提供了一种扩展能力。它更符合面向抽象开发的软件编程宗旨。
- 当具体的类型确定后,泛型又提供了一种类型检测的机制,只有相匹配的数据才能正常的赋值,否则编译器就不通过。
- 泛型提高了程序代码的可读性,不必要等到运行的时候才去强制转换,在定义或者实例化阶段,因为 Cache这个类型显化的效果,程序员能够一目了然猜测出代码要操作的数据类型。
通配符有 3 种形式。
<?>
被称作无限定的通配符。<? extends T>
被称作有上限的通配符。<? super T>
被称作有下限的通配符。
类型擦除
泛型信息只存在于代码编译阶段,在进入 JVM 之前,与泛型相关的信息会被擦除掉,专业术语叫做类型擦除
List<String> l1 = new ArrayList<String>();
List<Integer> l2 = new ArrayList<Integer>();
System.out.println(l1.getClass() == l2.getClass());//true
类型擦除带来的局限性
抹掉很多继承相关的特性,这是它带来的局限性。
IO 延伸到操作系统
常见的IO模型
- 按照流的流向分,可以分为输入流和输出流;
- 按照操作单元划分,可以划分为字节流和字符流;
- 按照流的角色划分为节点流和处理流。
Java IO 流共涉及 40 多个类,这些类看上去很杂乱,但实际上很有规则,而且彼此之间存在非常紧密的联系, Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
- InputStream/Reader: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。
- OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
既然有了字节流,为什么还要有字符流?
问题本质想问:不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?
回答:字符流是由 Java 虚拟机将字节转换得到的,问题就出在这个过程还算是非常耗时,并且,如果我们不知道编码类型就很容易出现乱码问题。所以, I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。
BIO 同步阻塞
应用发起read调用后,会一直阻塞,直到内核把数据拷贝到用户空间
适合客户端数量不多的情况
NIO 同步非阻塞
IO多路复用模型中,线程首先发起select调用,询问内核数据是否准备就绪,等内核数据准备好后,用户线程再次发起read调用。(read调用从内核空间到用户空间还是阻塞的)
select 调用 :内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
epoll 调用:linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。
IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
对于高负载,高并发的网络应用
客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。
应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的
I/O多路复用
线程首先发起select调用,询问内核数据是否准备就绪,好了以后,用户线程发起read调用,read调用的过程(数据从内核-》用户空间),还是阻塞的
通过减少无效的系统调用,减少对cpu资源的消耗
AIO 异步IO
基于事件和回调机制实现的,在应用操作之后会直接返回,不会阻塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作
流的类型
字节流
输入流,输出流
InputStream OutputStream
字符流
输入流,输出流
节点流
FIleInputStream FileOutputStream 直接来操作文件 read() read(byte[] b)
处理流
BufferedInputStream 缓存功能 提示效率
对象序列化,反序列化
ObjectInputStream ObjectOutputStream 反序列化 一种创建对象方式 深克隆
线程 延伸到操作系统,调度算法
线程,进程名词解释,关系
进程: 程序执行的一次过程,系统运行程序的基本单位
线程: 线程是进程更小的执行单位
多个线程共享进程的堆和方法区,每个线程有自己的程序计数器,本地方法栈,虚拟机栈
如何创建线程
- 继承Thread类,重写run方法,创建实例,执行start方法
- 实现Runable接口,实现run方法
- 实现callable接口,实现call方法,结合FutureTask类包装Callable对象,实现多线程
- 通过线程池创建线程,实现runnable接口,重写run方法,创建线程池,调用执行方法并传入对象,高性能,复用线程
线程的 run() 和 start() 有什么区别?
run():普通的方法调用函数,在主线程执行,不会新建一个新线程
通过start()启动一个线程进入就绪状态,等待cpu,然后线程调用run方法执行线程任务
Runnable和Callable的区别
都需要调用start方法启动线程
实现Runnable接口,重写run方法,不能抛出异常
实现callable接口,重写call方法,允许抛出异常,支持线程返回执行结果
Callable接口支持返回执行结果,此时需要调用FutureTask.get()方法实现,此方法会阻塞主线程直到获取‘将来’结果;当不调用此方法时,主线程不会阻塞!
线程间的同步的方式
- 互斥量(Mutex):采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制。
- 信号量(Semphares) :它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量
- 事件(Event) :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操
线程间通信方式
-
共享内存,基于 volatile关键字来实现线程间相互通信是使用共享内存的思想,大致意思就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。
-
消息传递,Object类提供了线程间通信的方法:wait()、notify()、notifyaAl(),它们是多线程通信的基础,而这种实现方式的思想自然是线程间通信。
-
使用JUC工具类 CountDownLatch,基于AQS框架,相当于也是维护了一个线程间共享变量state
线程的状态
-
新建
-
就绪: cpu调度进入运行状态
-
运行
-
阻塞: wait() sleep() join() i/o请求
-
死亡: 线程执行完毕或者异常
sleep: Thread的方法, 不会释放锁,抱着锁睡觉
yield: Thread的方法,暂停当前线程,执行其他,交出cpu使用权,不会释放锁
join: Thread方法,在主线程运行,让主线程休眠,不会释放锁,让调用join方法的线程先执行,在执行其他线程
wait: 释放锁
并发:并行
并发一个cpu,交替执行多个任务
并行: 多个cpu,同时执行多个任务
多线程优缺点
一个进程内允许多个线程执行
优点: 提高cpu的利用率,榨取cpu
缺点:
安全性,以及切换时候的开销大
死锁问题
可见性,原子性,有序性
java内存模型:
规范jvm和计算机内存的协同工作
缓解 cpu/io/内存之间的速度差异
可见性问题
如何访问共享变量
线程安全问题:并发编程
可见性
一个线程对共享资源进行操作后,另一个线程可以立即看到
产生原因
为了均衡内存和cpu的速度差异,在cpu加了缓存,为了提高效率,写缓冲区合并,在进行多次写操作时,缓存数据不会及时刷新到主内存中
处理
volatile,保证可见性进而有序性
当对volatile变量执行写操作后,JMM会把工作内存中的最新变量值强制刷新到主内存
写操作会导致其他线程中的缓存无效
具体原理: MESI缓存一致性协议
MESI协议保证了每个缓存中使用的共享变量的副本是一致的
核心的思想:多个cpu从主内存读取数据到高速缓存中,如果其中一个cpu修改了数据,会通过总线立即回写到主内存中,其他cpu会通过总线嗅探机制感知到缓存中数据的变化并将工作内存中的数据失效,再去读取主内存中的数据。
监听和通知又基于总线嗅探机制来完成
原子性
一个或多个操作在cpu执行时无法被中断
产生原因
多线程在多cpu运行,线程切换导致
解决
- synchronized锁
- 使用juc类下的Atomic原子类,它是使用CAS和volatile实现原子操作,类中的value有volatile修饰
有序性
程序按照代码的先后顺序执行
产生原因
编译器为了优化性能,会改变程序中的语句的先后顺序
解决
volatile关键字
volatile是通过编译器在生成字节码时,在指令序列中添加“内存屏障”来禁止指令重排序的
死锁
死锁根本原因:
是多个线程涉及到多个锁,这些锁存在着交叉,所以可能会导致了一个锁依赖的闭环;
一个线程T1持有锁L1,并且申请获得锁L2;而另一个线程T2持有锁L2,并且申请获得锁L1,因为默认的锁申请操作都是阻塞的,所以线程T1和T2永远被阻塞了。导致了死锁。
java 死锁产生的四个必要条件:
1、互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用(这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问))
2、不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。(占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。)
3、请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。(一次性申请所有的资源。)
4、循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
既然我们知道了产生死锁可能性的原因,那么就可以在编码时进行规避。
如何避免
1、避免嵌套锁
2、只锁需要的部分
3、避免无限期等待
银行家算法
当一个进程申请使用资源的时候,银行家算法通过先 试探 分配给该进程资源,然后通过安全性算法判断分配后的系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待。
守护线程
setDaemon() 等待所有线程执行完毕才会销毁
jvm里的GC 就是守护线程
线程间通信
线程与线程之间不是相互独立的个体,它们彼此之间需要相互通信和协作,最典型的例子就是生产者-消费者问题.
线程间的通信方式: wait/notify机制
生产者-消费者问题
/*柜台中 存放共享数据*/
public class Counter
int num = 0;
/*生产商品*/
public synchronized void add()
if (num == 0)
num++;
System.out.println("生产一个");
this.notify();//唤醒消费者线程 this表示同一个柜台
else
try
this.wait();//生产者等待
catch (InterruptedException e)
e.printStackTrace();
/*消费商品*/
public synchronized void sub()
if (num == 1)
num--;
System.out.println("消费一个");
this.notify();//唤醒生产者线程 this表示同一个柜台
else
try
this.wait();//消费者等待
catch (InterruptedException e)
e.printStackTrace();
/*消费者线程 */
public class Customer extends Thread
Counter c;
public Customer(Counter c)
this.c = c;
@Override
public void run()
while (true)
try
Thread.sleep(1000);
c.sub();
catch (InterruptedException e)
e.printStackTrace();
public class Productor extends Thread
Counter c;
public Productor(Counter c)
this.c = c;
@Override
public void run()
while (true)
try
Thread.sleep(1000);
c.add();
catch (InterruptedException e)
e.printStackTrace();
public class Test
public static void main(String[] args)
Counter c = new Counter();//创建柜台对象,是生产或者和消费者
Productor p = new Productor(c);
Customer ct = new Customer(c);
p.start();
ct.start();
线程面试题
Thread和Runnable的关系,区别
Thread是类,被继承,因为类只能单继承,所以比较死板,接口的话会很灵活,实现资源共享
Thread类 new Thread类直接start() runnable接口将类的对象注入new Thread中然后start方法
Runnable代码可以被多个线程共享使用
线程池里只能放入runnable或者callable的线程,不能方入Thread类的线程
谈谈synchronized,底层
synchronized 就是使用 monitorenter 和 monitorexit 这两个指令来实现
根据JVM规范的要求,在执行 monitorenter 指令的时候,首先要去尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,就把锁的计数器加1,相应地,在执行 monitorexit 的时候会把计数器减 1,当计数器减小为 0 时,锁就释放了。
java对象头
对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充
Java 对象头是实现 synchronized 的锁对象的基础,一般而言,synchronized 使用的锁对象是存储在 Java 对象头里。它是轻量级锁和偏向锁的关键
解决多线程访问资源的同步问题,被修饰的方法或代码块任意时刻只能被一个线程执行
监视器锁monitor依赖操作系统的Mutex Lock实现,线程是映射待操作系统的原生线程上,唤醒和挂起线程都需要操作系统完成,实现线程切换需要从用户态到内核态
java6 对synchronized优化,自旋锁,适应性自旋锁,锁消除,锁粗化,偏向锁,轻量级锁减少锁操作的开销
修饰实例方法:获得当前实例的锁
synchronized: 修饰方方法是静态的,取得的锁是对类的,类中的所有对象是同一把锁, 因为静态成员不属于任何一个实例对象,是类成员。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
修饰代码块
锁升级
- 偏向锁:一段同步代码一直被一个线程访问,那么线程会自动获取锁,降低获取锁的代价
- 轻量级锁:锁为偏向锁被另一个线程访问,会转为轻量级锁,这个线程会通过自旋方式尝试获得锁
- 重量级锁: 锁为轻量级锁时,自旋到一定次数没获得锁,进入阻塞,升级为重量级锁,会使其他线程阻塞,性能降低
JVM一般是这样使用锁和Mark Word的:
1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。
3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。
4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。
5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。
7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。
synchronized和ReentrantLock/Lock有什么区别呢?
ReentrantLock和synchronized都是独占锁,可重入锁,悲观锁
synchronized:
- java内置关键字
- 无法判断是否获取锁的状态,只能是非公平锁!
- 加锁解锁的过程是隐式的,用户不用手动操作,优点是操作简单但显得不够灵活
- 一般并发场景使用足够、可以放在被递归执行的方法上,且不用担心线程最后能否正确释放锁
ReentrantLock:
- 是个Lock接口的实现类
- 可以判断是否获取到锁,可以为公平锁也可以是非公平锁(默认)
- 需要手动加锁和解锁,且解锁的操作尽量要放在finally代码块中,保证线程正确释放锁
- 创建的时候通过传进参数true 创建公平锁,如果传入的是false或没传参数则创建的是非公平锁
- 底层是AQS的 state 和 FIFO 队列来控制加锁。
什么是AQS 抽象队列同步器
作用: 加锁会导致阻塞,有阻塞就有排队,实现排队必然需要某种形式的队列进行管理
原理: AQS中 维护了一个volatile int state(代表共享资源)和一个 FIFO 线程等待队列(多线程争用资源被阻塞时会进入此队列)。
这里volatile能够保证多线程下的可见性,当state = 1则代表当前对象锁已经被占有,其他线程来加锁时则会失败,加锁失败的线程会被放入一个 FIFO的等待队列中,并会被 UNSAFE.park() 操作挂起,等待其他获取锁的线程释放锁才能够被唤醒。
另外state的操作都是通过CAS来保证其并发修改的安全性。
使用的 ReentrantLock 非公平锁,线程进来直接利用CAS
尝试抢占锁,如果抢占成功state
值回被改为 1,且设置独占锁线程对象为当前线程。
线程一抢占锁成功后,state
变为 1,线程二通过CAS
修改state
变量必然会失败。此时AQS
中FIFO
(First In First Out 先进先出)队列中。
synchronized 关键字和 volatile 关键字的区别
synchronized
关键字和 volatile
关键字是两个互补的存在,而不是对立的存在!
- volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
- volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
AtomicInteger底层实现原理是什么? 包证原子性
CAS+volatile 可见性,有序性
比较并交换 无锁实现 乐观锁 自旋锁
AtomicInteger.getAndIncrement() == num++
底层是unsafe.getAndInt() 自旋锁
CountDownLatch(常问)
CountDownLatch是JDK提供的一个同步工具,它可以让一个或多个线程等待,一直等到其他线程中执行完成一组操作。
CountDownLatch有哪些常用的方法?
有countDown
方法和await
方法,CountDownLatch在初始化时,需要指定用给定一个整数作为计数器。
当调用countDown
方法时,计数器会被减1;当调用await
方法时,如果计数器大于0时,线程会被阻塞,一直到计数器被countDown
方法减到0时,线程才会继续执行。
调用countDown
方法时,线程也会阻塞嘛
不会的,调用countDown
的线程可以继续执行,不需要等待计数器被减到0,只是调用await方法的线程需要等待。
举一个使用CountDownLatch的例子
比如张三、李四和王五几个人约好去饭店一起去吃饭,这几个人都是比较绅士,要等到所有人都到齐以后才让服务员上菜。这种场景就可以用到CountDownLatch。
可以使用await
方法的另一个重载,传入等待的超时时间,比如服务员只等3秒钟,可以把服务员类中的
latch.await(3, TimeUnit.SECONDS);
CountDownLatch的实现原理是什么?
CountDownLatch有一个内部类叫做Sync,它继承了AbstractQueuedSynchronizer类,其中维护了一个整数state
,并且保证了修改state
的可见性和原子性。
在 countDown
方法中,只调用了Sync实例的releaseShared
方法,其中的releaseShared
方法,先对计数器进行减1操作,如果减1后的计数器为0,唤醒被await方法阻塞的所有线程
public final boolean releaseShared(int arg)
if (tryReleaseShared(arg)) //对计数器进行减一操作
doReleaseShared();//如果计数器为0,唤醒被await方法阻塞的所有线程
return true;
return false;
其中的tryReleaseShared
方法,先获取当前计数器的值,如果计数器为0时,就直接返回;如果不为0时,使用CAS方法对计数器进行减1操作
protected boolean tryReleaseShared(int releases)
for (;;) //死循环,如果CAS操作失败就会不断继续尝试。
int c = getState();//获取当前计数器的值。
if (c == 0)// 计数器为0时,就直接返回。
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc))// 使用CAS方法对计数器进行减1操作
return nextc == 0;//如果操作成功,返回计数器是否为0
在await
方法中,只调用了Sync实例的acquireSharedInterruptibly
方法
public void await() throws InterruptedException
sync.acquireSharedInterruptibly(1);
其中acquireSharedInterruptibly
方法,判断计数器是否为0,如果不为0则阻塞当前线程
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)//判断计数器是否为0
doAcquireSharedInterruptibly(arg);//如果不为0则阻塞当前线程
其中tryAcquireShared
方法,是AbstractQueuedSynchronizer中的一个模板方法,其具体实现在Sync类中,其主要是判断计数器是否为零,如果为零则返回1,如果不为零则返回-1
protected int tryAcquireShared(int acquires)
return (getState() == 0) ? 1 : -1;
ThreadLocal的底层原理
包证每个线程中会存储一份变量 ThreadLocalMap 容量为 16。
ThreadLocal 可以看作是一个map集合,key就是当前线程,value就是要存放的变量。
ThreadLocal 对象可以给每个线程分配一份属于自己的局部变量副本,多个线程之间可以互不干扰。一般我们会重写 initalValue()
方法来给当前 ThreadLocal 对象赋初始值。
每个线程 Thread 都有一份属于自己的 ThreadLoacalMap 用于存储数据。
可能会造成内存泄漏 键是弱引用,值是强引用
解决:使用完后,手动删除
ThreadLocalMap 的 扩容阈值是多少?它的扩容机制是怎样的?
首先,ThreadLocalMap 的扩容阈值为初始容量的 2/3,当数组中,存储 Entry 节点的个数大于等于 2/3 时,会它并不会直接开始扩容。
而是先调用 rehash()方法,在该方法中,全面扫描整个数组,并将数组中过期的数据(key == null)给清理掉,重新整理数组。
如果重新整理数组,并将过期的数据清理后,再次重新判断数组内的 Entry 节点的个数是否达到扩容阈值的3/4,如果达到再调用真正扩容的方法resize();
简单描述一下JDK1.8中ThreadLocal原理
- JDK8 中,每个线程对象 Thread 类内部都有一个成员属性 threadLocals(即ThreadLocalMap,它是一个Entry[]数组,而不是 Map 集合哦~),各个线程在调用同一个 ThreadLocal 对象的set(value)方法设置值的时候,就是往各自的 ThreadLocalMap 对象数组中新增值。
- ThreadLocalMap (Entry[]数组)中存放的是一个个的 Entry节点,它有两个属性字段,弱引用 key(ThreadLocal对象) ,和强引用 value (当前线程变量副本的值)。
ThreadLocal是怎样坐到线程互不干扰的呢(线程隔离)?
首先,每个线程 Thread 都有
以上是关于JavaSE学习总结(十三)Set集合HashSet集合LinkedHashSet集合TreeSet集合比较器的使用利用Set集合实现去重的主要内容,如果未能解决你的问题,请参考以下文章