一)解决哈希冲突的方法有哪些?
哈希冲突指的是在哈希表中,不同的键值映射到了相同的哈希桶,也就是数组索引,导致键值对的冲突
1)设立合适的哈希函数:通过哈希函数计算出来的地址要均匀的分布在整个空间中
2)负载因子调节:
2.1)开放地址法:
1)当发生哈希冲突时,如果哈希表中没有装满,说明哈希表中一定还有空余位置,那么可以把key放到冲突位置的下一个空位置去,从发生冲突的位置开始,依次次向后探测,直到找到下一个空位置为止
2)当插入数据的时候:先通过哈希函数计算到待插入元素在哈希表中的位置,如果该位置中没有元素就直接插入新元素,如果该位置中有元素就使用线性探测找到下一个空位置,直接插入元素,从冲突的位置开始,向后进行探测,把元素放进去
3)但是他会容易把冲突的元素会放在一起,还不可以随意地删除元素,例如把4删掉,44查起来,44进行查询需要依赖于4下标会受到影响,需要加上一个标志位flag,没删除是0,删除了是1,所以说在我们进行闭散列来进行处理哈希冲突的时候,不可以随便删除哈希表中原有的元素,若删除此元素会影响其他元素的查找
2.2)二次探测:
1)线性探测的缺点是将产生冲突的元素放到了一起,堆积到了一块,这与其和找下一个空位置有关系,但是二次探测为了解决该问题,找下一个空位置的方法发生了变化
2)放的位置在H(i)=(H(0)+i^2)%m,保证放的数据不会紧挨着,i代表第几次冲突,空间利用率比较低,更加均匀的分配了元素
2.3)链式地址法:
2.4)再哈希法:当发生哈希冲突的时候再次使用另一个哈希函数计算出一个新的哈希值,然后将元素插入到对应的位置
二)HashMap底层是如何进行实现的?
Hashmap集合在jdk1.7的版本(采用头插法),底层是通过数组加链表实现的,在jdk1.8的版本底层是中是通过数组+链表+红黑树实现的,为什么要引入红黑树呢,因为链表的查询效率太低,例如在老版本中通过key算出的index(不同的数据通过哈希函数算出的index相同)得知发生冲突的情况下,会存放到同一个链表中,查询效率非常低,因为会从头查到尾,时间复杂度就是O(n),所以在jdk1.8(采用尾插法)中引入了红黑树,当数组长度大于64况且链表长度大于8时,就会直接将链表转化成红黑树
HashMap的数据结构在jdk1.8之前是数组+链表,为了解决数据量过大、链表过长是查询效率会降低的问题变成了数组+链表+红黑树的结构,利用的是红黑树自平衡的特点。
链表的平均查找时间复杂度是O(n),红黑树是O(log(n))
三)为什么HashMap一定要使用红黑树?
1)AVL树,因为AVL树和插入和删除节点,整体的性能不如红黑树,在AVL树中,每一个节点的平衡因子是左子树高度和右子树高度的差值,平衡因子只能是-1,1和0,当插入和删除元素的时候,任何节点的平衡因子超过了这个范围,就要通过左旋,右旋,左右双旋右左双旋这样的操作来让AVL树保持平衡,但是红黑树相对来说比较宽松,插入和删除操作会导致比较少的旋转操作,因此在频繁的插入和删除的条件下,红黑树的性能可能要高于AVL树
2)二叉搜索树:左右节点极其不平衡,可能会退化成链表
3)对于结点平衡的要求没有那么特别高,相比于AVL树来说相对来说比较宽松
四)什么是负载因子?
负载因子也叫做扩容因子,本质上是一个用于HashMap何时进行扩容的参数,计算方法就是存入表中的元素的个数/表的大小,当HashMap存储的键值对数量超过了HashMap总容量乘以负载因子的时候,就会发生扩容操作
如果所有数组存满了就扩容,那么随着时间的推移,插入时间就越长,哈希冲突也会变得越来越高
五)为什么HashMap的负载因子是0.75
不仅要平衡性能也要平衡空间的利用率,这个值被认为是时间和空间效率上面之间的一个较好的平衡点
1)当负载因子比较大的时候,那么就意味着发生扩容的时间比较晚,此时哈希表中存储更多的元素才会发生扩容,空间利用率就会比较高,但是发生哈希冲突碰撞的概率就会比较大,增删查改一个元素的时间就会变长;
2)当负载因子比较小的时候,那么扩容会比较早,此时哈希表数组中存储的元素比较少的时候就发生哈希冲突从而进行扩容了,哈希冲突发生的概率比较低,插入的时间会变快,但是空间利用率就会变得非常的低,增删改查的效率会比较高;
3)StackOverFlower上面有一个大神,进行了推导,假设一个哈希桶为空和非空的概率是0.5,那么就是他推导出如果桶中的元素的数量小于log2/log(s*(s-1))的时候,对应的哈希桶可能是空,当s无穷大的时候,这个值等于0.693当里面的值小于0.693的时候,桶的有可能是空
六)说一下hashcode和equals的区别
1)hashcode是指定当前引用类型当前元素,当需要将一个引用类型放到散列表中,就需要重写hashcode生成在数组中的下标
2)equals方法是需要在hashcode定义的数组下标中,遍历链表,判断哪个key是和当前的key是相同的,比较引用类型所指向的对象中的具体内容
衍生问题:
一)如果两个数据的hashcode相同,equals一定相同吗?
不一定,但是这两个数据一定哈希到了同一个位置
二)如果两个数据的hashcode不同,equals不一定相同吗?
一定不相同,在数组中的位置都不一样; 如果两个数据的equals相同,那么内容一定相同,此时的hashcode也是相同的;
三)如果newhashMap(19),那么哈希表的数组有多大?
当指定大小的时候,哈希表的数组容量一定是2的多少次幂,所以找超过指定容量的2的多少次幂最靠近19,2的5次幂是32;
四)hashmap什么时候开辟bucket数组,占用内存?
HashMap当第一次put元素的时候,默认容量是16,最接近值的2次幂
五)hashmap什么时候会进行扩容?
当负载因子超过0.75的时候
六)Hashmap链表长度为8时转换成红黑树,你知道为什么是8吗?
1)当链表长度大于或等于8的时候,如果同时还满足容量大于或等于64的时候,就会把链表转换为红黑树,同样,后续如果由于删除或者其他原因调整了大小,当红黑树的节点小于或等于 6 个以后,又会恢复为链表形态;
2)每次遍历一个链表,平均查找的时间复杂度是O(n),n 是链表的长度,由于红黑树有自平衡的特点,可以防止不平衡情况的发生,所以可以始终将查找的时间复杂度控制在log(n)
3)最初链表还不是很长,所以可能 O(n) 和 O(log(n)) 的区别不大,但是如果链表越来越长,那么这种区别便会有所体现。所以为了提升查找性能,需要把链表转化为红黑树的形式。
4)通常如果 hash 算法正常的话,那么链表的长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。
5)个人觉得是数据量较少时,红黑树会频繁的发生左旋或者右旋,浪费cpu性能,所以加入了链表,单个 TreeNode 需要占用的空间大约是普通链表Node 的两倍,而当桶中节点数由于移除或者 resize 变少后,又会变回普通的链表的形式,以便节省空间,如果要性能就需要牺牲空间,要空间就要牺牲性能;
七)HashMap的put操作:
1)首先会调用函数叫做hash(key)这个函数最终的返回值是根据当前的key返回一个32位的散列码也就是hashcode,具体做法是key.hashcode()^(key.hashcode<<<16),低16位和高16位进行位运算,保证数据均匀分布;
但是如果说当前元素插入的是一个null值,那么这个key则会默认放到数组的0号位置
2)接下来就会调用putVal()方法
2.1)首先会进行判断哈希表是否为空,或者是说数组的长度等于0,那么直接进行初始化数组
2.2)然后扩容完成之后,会根据之前通过hash函数计算出来的散列码来进行计算当前这个key要存放到数组的哪一个下标,在这里是通过(n-1)&hashcode来计算位置,但是这里面为什么不是hashcode%n,因为对于任意的n来说(n-1)&hashcode=hashcode%n(是任意数字)
2.3)判断数组当前下标有没有元素,如果没有,就进行设置该节点
2.4)再进行判断当前数组的位置是否和当前要插入的key相同,如果相同,直接覆盖value
2.5)如果当前数组的位置不等于key,那么再进行判断当前是否是红黑树,如果是红黑树,直接插入节点
2.6)如果不是红黑树,说明当前结构是链表,那么直接遍历链表,找到链表的最后一个位置,那么直接使用尾插法插入当前元素,如果在遍历的过程中发现key已经重复了,那么直接覆盖value
2.7)如果数组长度大于64&&链表长度大于8,那么直接转化成红黑树进行处理
3)如果超过负载因子,进行扩容
1)当调用没有参数的构造方法的时候
当数组长度是0或者数组的引用为空的时候,第一次put操作的时候,就会执行reasize()的方法来进行扩容,默认的初始容量是16;
2)根据哈希值来进行计算索引的时候,在寻找数组的下标的时候,在咱们之前的代码中时使用Key的哈希值%数组长度,但是在HashMap的源码中是用数组下标=(数组长度-1)&hash)
4&15==4%16hash%n-1的值(也就是得到位置)相等,位运算的速度更快,效率更高;
八)为什么HashMap数组的容量必须是2^N?
(n-1)&hash保证n是偶数
1)如果n是偶数,那么n-1的最后一位一定是1,当与hash码(hash码最后一位有可能是进行0也有可能是1)&运算的时候,得到的最后一位是0或者1;
最终得到的数组下标可能是奇数也有可能是偶数
2)如果n是奇数,那么n-1的最后一位是0,那么与hash函数进行&操作的时候,会得到的下标的最后一位一定为0;最后只能得到偶数下标;
3)就是说我们以初始容量为16来进行举例,16-1=15,那么15的二进制序列就是001111,我们可以看出一个奇数二进制最后一位必然是1,当一个hash值参与运算的时候,最后一位可能是1,也有可能是0,当一个偶数和hash值进行与运算的时候最后一位必然是0,会造成有些位置永远也无法映射上值
4)保证数组容量是偶数,才可以保证最后的下标即是奇数下标又是偶数下标
九)HashMap和HashTable有什么区别?
1)两者最主要的区别在于Hashtable是线程安全性能比较低,而HashMap则非线程安全安全性比较高
2)HashMap可以使用null作为key,不过建议还是尽量避免这样使用,HashMap以null作为key时,总是存储在table数组的第一个节点上,但是hashtable这样的线程安全的集合类容器不允许插入空的key和value的,在咱们ConcurrentHashMap和HashTable的源码当中,如果key为空,或者value为空,直接抛出空指针异常
4)HashMap的初始容量为16,Hashtable初始容量为11,两者的填充因子默认都是0.75。
5)HashMap扩容时是当前容量翻倍即:capacity*2,Hashtable扩容时是容量翻倍+1即:capacity*2+1。
6)HashMap数据结构:数组+链表+红黑树,Hashtable数据结构:数组+链表。7)计算哈希值也是不同的:hashMap先计算出哈希值,然后无符号右移16位,然后再进行按位异或操作,但是hashTable直接按位与0x7FFFF得到散列码;
int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length;
HashTable是针对当前的this来进行加锁的,当有多个线程来访问HashTable的时候,无论是啥样的操作,无论是啥样的数据,都会造成锁竞争,这样的设计就会导致程序执行效率会非常低,导致锁竞争的概率是非常大的;
1)像上面的直接给主要的方法进行加锁的时候,当我们给直接将HashTable对象本身加上锁,如果说多个线程访问同一个Hashtable就会直接造成锁冲突,每一个HashTable都有一把锁,想要经过HashTable的任意元素来进行操作,都是需要经过这把锁的才可以进行访问;
2)size属性也是通过synchronized来进行控制同步操作,这也是比较慢,一旦触发扩容,就会导致由这个线程来进行完成扩容,这个过程会涉及到大量的元素拷贝,效率会非常低;
十)HashMap为什么会产生死循环和数据覆盖等问题?
HashMap产生死循环的原因就是因为数据扩容是头插法,形成环形链表
再加上是多线程的并发扩容操作造成了死循环的问题
死循环产生步骤1:
死循环是并发HashMap进行扩容导致的,并发扩容的第一步,线程T1和线程T2需要同时进行扩容操作,此时T1线程和T2线程指向的是链表的头节点A,而T1.next和T2.next指向的下一个节点就是B
死循环产生步骤2:此时T1线程扩容完成,T2线程被唤醒,也开始执行扩容操作
死循环执行步骤3:因为T2线程被唤醒之后,T1线程刚刚完成扩容,T1完成扩容之后B的下一个节点是A,但是此时T2线程此时指向的头结点是A,下一个节点是B,T1执行完的顺序是从A到B,T2执行完的顺序是从B到A,此时就发生了死循环
十一)HashMap产生的数据覆盖的问题:
数据覆盖问题:多个线程同时并发的向同一个位置去更新元素或者是添加元素
先要想某一个位置添加元素,这并不是一个原子性的操作
1)先判断这个位置是否可以添加元素,结果判断当前位置没有元素
2)因为第一步已经判断当前位置没有元素,所以直接将这个元素添加到此位置
当两个线程并发执行这两个操作的时候,可能就会出现问题
1)线程T1进行添加的时候,通过key计算出一个hashcode计算出数组的一个位置,判断这个位置可以进行插入元素了,但还没有真正地进行插入操作,时间片就用完了,此时线程1已经判断好这个位置是空了,刚刚要进行插入操作,就被调度器给抢走了
2)线程2也想要进行插入操作,通过key计算出一个hashcode计算出数组的一个位置恰好和线程1要插入的元素的位置相同,并且T2要进行插入的数据key和T1要进行插入的数据key是不相同的,但是线程1和线程2计算出来的hashcode是相同的,由于此位置没有任何元素,T1只是进行判断,刚想要插入值就被调度器给抢走了,于是此时线程2先判断当前位置没有值,就把自己T2线程的值存入到当前位置
3)T1线程恢复执行之后,因为非空判断已经执行完了,T1线程当前是无法感知当前位置已经有值了,因为已经判断完了,于是就把自己的值插入到了该位置,于是T2线程插入的值就被覆盖了
一)ConcurrentHashMap如何保证线程安全?
前置:分段锁就是针对数组元素进行分组,多个哈希桶也就是多个数组元素共同分配一把锁,Concurrent的segment里面会存在着多个哈希桶(哈希桶1,哈希桶2,哈希桶3),当线程1和线程2想要并发的修改哈希桶1和哈希桶2,就会排队等待获取到这个片段锁,线程1获取锁之后修改哈希桶1,线程2获取到锁之后才能对哈希桶2进行操作,因为锁的粒度太大,性能太低,大家使用的是同一把锁,可能会导致程序的执行效率太低,比如说有100个线程并发修改哈希桶1,哈希桶2,哈希桶3,那么这100个线程都是竞争着同一把锁,所以就会导致线程安全问题,但是假设我们来看一下,并发修改哈希桶1和并发修改哈希桶2似乎也不会产生线程安全问题
2)JDK1.8之后,使用粒度更小的锁,一个哈希桶对应一把锁,所以此时锁的粒度越小,高并发情况下,程序运行的效率变得更高
一定是锁的粒度越小越好吗?
不是所有的情况下锁的粒度都是越小越好的,高并发情况下才可以发挥出锁粒度比较小的优势的,但是相比来说非高并发情况下使用Segment分段锁比较合适,因为加锁是存在着性能消耗的,如果加更多的锁会有更多的消耗,如果有更多的消耗自己又用不上的情况下,浪费了资源,性能也比较高
1)ConcurrentHashMap在JDK1.7使用的是数组+链表,分成大数组Segment,和小数组HashEntry,这个大数组中的每一个Segment中的每一个元素是一段小数组,小数组中的每一个元素叫做HashEntry,每一个HashEntry里面又包含了多个多组数据,这些数据又是通过链表来进行连接的
2)Segment本身是基于Reentrantlock来实现加锁和释放锁的操作,这样就可以保证多个线程同时访问ConcurrentHashMap,同一时间内只有只有一个线程能够操作对应的节点。只能操作大数组中的一个元素,也就是说ConcurrentHashMap是建立在Segment加锁的基础上的,所以说我们把它称之为分段锁或者片段锁,因为是数组加链表的形式,所以访问是比较慢的,因为要遍历整个链表
3)JDK1.8是实现数组+链表+红黑树,使用synchronized和volatile和CAS综合实现的
CurrentHashMap再进行操作元素的时候,是在针对这个元素的头节点来进行加锁的,如果说两个线程操作是针对两个不同链表上面的元素,没有线程安全问题,其实就不用加锁
1)但是当我们的两个线程同时进行修改一个链表上面的元素的时候就会涉及到线程安全问题,我们没有必要说让锁的粒度那么大,我们只需要控制好每一个链表就可以了
2)由于在hash表中,链表的数目非常多,但是每一个链表的长度相对来说是比较短的,这样子就可以保证锁冲突的概率是非常小的,这样我们把每一把锁分配到每一个链表的时候,锁冲突就会变小了;
1)在JDK1.8中,添加元素会先进行判断数组是否为空,如果为空直接用CAS+volatile来进行初始化
2)如果不为空就进行判断当前要存储的位置是否为空,如果为空就利用CAS来进行设置该节点,如果不为空直接使用synchronized来进行加锁,遍历桶中的数据,替换或者新增节点到桶里面
3)最后再判断一下是否要转化成红黑树
4)ConcurrentHashmap对读没有加锁,而是直接使用volatile,评估者说读操作对实际开发没有影响,读操作没有进行加锁目的是为了降低锁冲突的概率,为了可以读到刚才修改的数据,搭配了volatile关键字;
5)充分利用了CAS的特性,例如获取元素个数;
6)Hashtable的扩容,如果某次put操作,导致当前的元素太多,此时就会触发扩容,这个扩容就会需要构建一个更大的内存,并且把每一个数据都复制一份,这就会直接导致这次插入操作会非常低效,但是对于ConcurrentHashmap来说,他的基本思路就是化整为零,如果针对某次操作触发了扩容操作,不是一口气扩容完,而是只搬运一部分.....下次再对ConcurrentHashmap进行操作时,在搬运一点点,通过多次操作完成整个搬运的过程,直到搬运完毕就会销毁旧的数据也就是哈希表,保证每次操作都不至于太慢。在这个搬运过程中,相当于系统维护了两份内存,一份是的数据,一份是新的数据,插入操作就往新数据里面插,查找操作,同时查找的数据和新的数据,当全部搬运完成,再删除旧的数据
JDK1.8的优化:(重点)
1)总结:ConcurrentHashMap是在头节点进行加锁来实现线程安全的,锁的粒度相比于Segment来说更小了,发生冲突和加锁的频率变低了,并发操作的性能就变高了
2)并且JDK1.8使用的是红黑树优化之前的固定链表,当数据量比较大的时候,查询性能也得到了极大的提升,从之前的O(N)优化到了log(N)时间复杂度
1)ConcurrentHashmap其实也是使用synchronized加锁,综合使用synchronized和volatile,但是他的加锁方式和Hashtable有着很大区别,例如此时使用Hashtable,有十个线程并发的修改hashtable,此时十个线程都在尝试获取锁,竞争一把锁,而且锁冲突的概率比较高)
2)但是ConcurrentHashMap的锁对象,是针对数组的每一个元素进行加锁,也就是针对每一个哈希桶进行加锁,数组的每一个元素是链表,之前hashtable只有一把锁,但是现在数组的每一个元素都有一把锁;
3)此时如果再有十个线程并发修改哈希表,此时如果当前线程计算出的数组hashcode%数组长度是不相同的,此时就不会发生锁的竞争,但是如果有两个线程修改的数据正好在同一个数组位置上,就会发生锁的竞争,相当于把一个大锁分成了若干个小锁,每一个数组加一把锁,这样就减轻了锁冲突的概率,这样的效率是特别高的,hashtable的加锁操作几乎都变成了串行执行;
4)ConcurrentHashmap针对修改操作的加锁,使用的是粒度更小的锁,针对每一个哈希桶的数组下标来设定一个锁,这是针对JDK1.8版本来说的,但是在JDK1.8版本之前,使用的是分段所,相当于把这些哈希桶分成若干个组,每个组配一个锁;
二)为什么ConcurrentHashMap不允许插入空值?
1)使用ConcurrentHashMap这样的线程安全的容器,key或者value不允许Key和Value插入空值,但是HashMap是允许Key或者,Value插入空值的,原因是人家源码一开始就说了如果key或者value为空,直接抛出空指针异常
2)所谓的二义性问题是指含义不清或者目标不明确,我们假设concurrentHashMap允许插入空值,那么此时就会出现歧义:
2.1)值没有在集合里面,所以返回null
2.2)值就是null,返回的就是原来的null
可证伪的HashMap
上面说的HashMap是不怕二义性问题的,因为HashMap本来设计给单线程来进行使用的,但是如果查询到了null值,我们就可以通过containsKey(key)的方法来继续判断区分这个null,到底是咱们插入的null,还是压根不存在的null
不可证伪的ConcurrentHashMap
但是ConcurrentHashMap使用的场景是多线程,情况会变得更加复杂
1)假设ConcurrentHashMap可以存入null值,现在有一个线程A调用了concurrentHashMap.contains(key),我们期望现在返回的结果是false
2)但是当我们的线程1调用了concurrentHashMap.contains(Key)之后,未返回结果之前,线程B又调用了concurrentHashMap(null,null)存入了null值,那么此时线程A返回的结果就是true,这个和我们之前预想的完全不一样,
总结:无法判断当前是某一个一个时刻返回的null值,到底是值为null,还是压根就不存在,二义性问题不可被证伪
三)Vector和ArrayList的区别:
ArrayList和Vector都是基于动态数组进行是实现的
1)线程安全性:ArrayList是线程不安全的,Vector是线程安全的
2)扩容:ArrayList1.5倍扩容,Vector2倍扩容
3)性能:ArrayList>Vector
四)死锁是什么?是怎么产生的?如何解决?
死锁是两个或者是两个以上的运算单元(进程,线程,协程)都在等待对方释放资源,但是没有一方释放资源,从而造成了一种阻塞的情况就叫做死锁
1)互斥使用,如果一个锁被另一个线程占用的时候,别的线程基本就会进行阻塞等待,别的线程是无法占用
2)不可抢夺,不可剥夺性,线程1如果获取到一把锁,线程二不可以强行把锁抢过来;
3)请求和保持,当现在资源的请求者在请求其他资源时,同时要保持之前的资源,线程获取到锁1的时候,再尝试获取到锁2,此时仍然保持对锁1的持有,右手筷子拿不起来,左手筷子也是不想放手的,当我们的一个线程占据了多把锁之后,除非显式地释放锁,否则这些锁都是该线程所持有的
4)循环等待,线程1先尝试获取到锁一和锁二,线程二尝试获取锁二和锁一,这样的情况就是循环等待
public class Demo { public static void main(String[] args) { Object locker1=new Object(); Object locker2=new Object(); Thread t1=new Thread(()->{ synchronized (locker1){ try { System.out.println("线程1获取到了锁1"); TimeUnit.SECONDS.sleep(3); synchronized (locker2){ System.out.println("线程1获取到了锁2"); } } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t2=new Thread(()->{ synchronized (locker2){ try { System.out.println("线程2获取到了锁2"); TimeUnit.SECONDS.sleep(3); synchronized (locker1){ System.out.println("线程2获取到了锁1"); } } catch (InterruptedException e) { e.printStackTrace(); } } }); t1.start(); t2.start(); } }
有的时候不是因为多线程竞争阻塞而导致死锁,而是因为程序写错了,没有写解锁操作的代码,忘记写unlock了
1)互斥使用:两辆车同时过一座桥,产生竞争关系,同一时刻一座桥只能过一辆车
2)请求和保持:如果有一辆车提前将资源释放了,谁都不愿意让路,提前让路了,那么也不会产生死锁的问题了,谁都不愿意提前释放锁
3)不可剥夺性:谁都不可以命令谁让路
4)环路等待:对方都在等待对方的条件,只能选择这一条路
死锁的知识拓展:
哲学家共餐问题:5个哲学家,5只筷子,哲学家要么思考人生,要么拿起筷子吃面条,极端情况下就会出现死锁,如果同一时刻,所有的哲学家都同时拿起左边的筷子,然后再同时尝试拿起右边的筷子;
按照编号,从小到大进行加锁,滑稽老铁优先选择编号较小的筷子,如果旁边有一个比较小的筷子被别人拿了,就会阻塞等待;
咱们只需要约定好,针对多把锁进行加锁的时候,我们有固定的顺序就好了,当我们所有的线程都遵守同样的规则顺序,就不会出现环路等待;
分布式系统加锁的概念:
分布式锁不再依赖于synchronized,也和我们的编程语言无关了,存在的意义就是在一个分布式的系统中(存在很多台机器)能够起到互斥的效果
在实际工作过程中,如果在一个分布式系统的过程中存在很多机器,就可以起到互斥的效果
例如现在有应用服务器1,和应用服务器2,此时两个服务器都想要向目标机器数据库中写数据,就可能出现错误,类似于线程安全问题,这是我们就在引入一个服务器,如果那个应用服务器想写数据,就必须调用另一个服务器的lock接口,写完之后再调用unlock,比如默认数据是0,调用lock就改成1,调用unlock就改成0;
假设我们现在有两个服务器想要往数据库中写数据,第一台应用服务器调用备用服务器的lock接口,把0给成1,但是假设在修改数据的过程中,第二台应用服务器也想修改数据,发现是1,就不能修改
死锁问题:例如在lock的过程中,主机宕机导致所无法释放(无法调用unlock),出现的死锁,此时就可以给锁加一个过期的时间,例如过了三秒还没有解锁,就自动解锁;
如何解决死锁?
1)顺序锁:破坏环路等待条件
public class Demo { public static void main(String[] args) { Object locker1=new Object(); Object locker2=new Object(); Thread t1=new Thread(()->{ synchronized (locker1){ try { System.out.println("线程1获取到了锁1"); TimeUnit.SECONDS.sleep(3); synchronized (locker2){ System.out.println("线程1获取到了锁2"); } } catch (InterruptedException e) { e.printStackTrace(); } } }); Thread t2=new Thread(()->{ synchronized (locker1){ try { System.out.println("线程2获取到了锁1"); TimeUnit.SECONDS.sleep(3); synchronized (locker2){ System.out.println("线程2获取到了锁2"); } } catch (InterruptedException e) { e.printStackTrace(); } } }); t1.start(); t2.start(); } }
2)轮询锁:破坏请求和保持,请求和保持条件可以破坏,trylock就是使用这种方法,轮询锁打破了请求和保持,轮询锁本质上是破坏请求和保持这个条件的来避免死锁的,他的实现就是简单来说是通过轮询的方式来尝试获取到锁,如果有一个锁获取失败,那么直接释放当前线程所拥有的所有的锁,等待下一轮在来尝试获取到锁,轮询锁的实现需要使用到ReentrantLock中的trylock方法
package spi; import java.util.ServiceLoader; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class Demo { public static void main(String[] args) { Lock lock1=new ReentrantLock(); Lock lock2=new ReentrantLock(); Thread t1=new Thread(()->{ try { lock1.lock(); System.out.println("线程1获取到了锁1"); Thread.sleep(1000); try { lock2.lock(); System.out.println("线程1获取到了锁2"); }finally { System.out.println("线程1获取到了锁2"); lock2.unlock(); } } catch (InterruptedException e) { e.printStackTrace(); }finally { System.out.println("线程1获取到了锁1"); lock1.unlock(); } }); Thread t2=new Thread(()->{ try { pollinglock(lock1,lock2); } catch (InterruptedException e) { e.printStackTrace(); } }); t1.start(); t2.start(); } private static void pollinglock(Lock lock1, Lock lock2) throws InterruptedException { while(true) { if (lock2.tryLock()) { System.out.println("线程2获取到了锁2"); try { Thread.sleep(3000); if(lock1.tryLock()){ System.out.println("线程2获取到了锁1"); try{ Thread.sleep(1); }finally { System.out.println("线程2释放了锁1"); lock1.unlock(); break; } } } finally{ System.out.println("线程2释放了锁2"); lock2.unlock(); } } Thread.sleep(4000); } } }
如何排查死锁:
1)jstack:
1.1)首先通过jps -l得到所有的java程序,jps是java提供的一种可以显示当前java所有的进程ID的命令,-l用于输出进程 pid 和运行程序完整路径名,包名和类名
1.2)然后通过jstack -l pid来解决问题
2)打开JDK的bin目录,找到jconsole并选择打开,选择要调试的程序,直接双击,选择不安全的链接
3)jmc和jvisualvm
五)Reentranlock和synchronized的区别?
1)底层实现不同:synchronized底层是依靠JVM层面的监视器锁实现的,moniterenter表示进入监视器,相当于是加锁的操作,moniterexit表示是退出监视器,是进行解锁的操作但是Reentranlock底层是依据AQS实现的,是程序级别的API的实现,在Reentranlock内部会存在一个state的标记字段,用于表示锁是否被占用,如果是0就表示锁没有被占用,那么当前线程就可以将这个state修改成1,如果state是1就表示锁已经被占用,并只能去排队去等待该资源
2)获取锁和释放锁的方式不同:synchronized使用时不需要手动释放锁,和手动获取到锁,当进入到synchronized修饰的代码块会自动加锁,当我们离开synchronized修饰的静态代码块会自动进行解锁,但是Reentrantlock是通过需要手动加锁,手动释放使用起来会更灵活,但是也容易泄露unlock,会造成死锁;
还有就是要注意使用Reentranlock要特别小心,unlock释放锁的操作一定要在finally语句块里面,否则程序如果出现异常,就会出现锁一直被占用,导致其他线程一直被阻堵塞的问题;
3)用法不同:synchronized可以用来修饰普通方法,静态方法和代码块,但是Reentranlock只能修饰同步代码块,但是ReentranLock只能修饰同步代码块;
4)锁类型不同:synchronized是非公平锁,Reentrantlock默认是非公平锁,但是在构造方法里面加入一个true开启,公平锁模式;
5)等待锁的方式不同:synchronized在竞争锁失败时会进行阻塞等待,但是Reentrantlock会通过trylock的方式,等待一会就直接返回;
6)唤醒机制不同:synchronized是通过Object的wait和notify来实现等待唤醒,每次唤醒的是一个随机等待的线程,Reentrantlock是搭配Condition类来实现等待唤醒,此时就可以更精确的唤醒指定的一组线程;
7)响应中断不同:ReentranLock可以使用lockInterruptiby获取锁并响应中断指令,会自动放弃锁的占有,但是synchronized不能响应中断,也就是说一旦发生了死锁,使synchronized会一直等待下去,而是用ReentrankLock可以响应中断并且释放锁,从而解决死锁问题;
public static void main(String[] args) throws InterruptedException { ReentrantLock lock1 = new ReentrantLock(); ReentrantLock lock2 = new ReentrantLock(); Thread t1 = new Thread(() -> { try { System.out.println("线程1获取到锁1"); lock1.lockInterruptibly(); //休眠10ms TimeUnit.SECONDS.sleep(100); System.out.println("线程1获取到锁2"); lock2.lockInterruptibly(); } catch (InterruptedException e) { e.printStackTrace(); } finally { System.out.println("线程1释放锁1"); lock1.unlock(); System.out.println("线程1释放锁2"); lock2.unlock(); } }); Thread t2 = new Thread(() -> { try { System.out.println("线程2获取到锁2"); lock2.lockInterruptibly(); //休眠10ms TimeUnit.SECONDS.sleep(100); System.out.println("线程2获取到锁1"); lock1.lockInterruptibly(); } catch (InterruptedException e) { e.printStackTrace(); } finally { System.out.println("线程2释放锁2"); lock2.unlock(); System.out.println("线程2释放锁1"); lock1.unlock(); } }); t1.start(); t2.start(); TimeUnit.SECONDS.sleep(10); t1.interrupt(); }
线程同步:线程同步是指多线程通过特定的东西(如互斥量)来控制线程之间的执行顺序(同步) 也可以说是在线程之间通过同步建立起执行顺序的关系例如,信号量,CountDownLock等等
六)有synchronized这个方法,当两个线程同时用这两个方法时会发生什么?
非静态方法:给对象加锁,这时候,在其他一个以上线程中执行该对象的这个同步方法(是该对象)就会产生互斥,如果这两个方法不属于同一个实例,那么互不打扰,各忙各的,如果这两个方法属于同一个实例,那么线程一就会获取到锁并执行这个方法,此时线程二就会阻塞等待直到线程一释放了锁才可以执行方法内容;
静态方法:相当于在类上加锁,这时候,只要是这个类产生的对象,在调用这个静态方法时都会产生互斥
七)CopyOnwriteArrayList?多线程环境下使用ArrayList:
1)使用同步机制(synchronized或者Reentrantlock);
2)使用collections.synchronizedList(new ArrayList),方法全部加锁,synchronizedList是标准库中提供的一个基于synchronized实现线程同步的List,synchronizedList的关键操作都带有synchronized
3)使用CopyOnWriteArrayList,写实拷贝:
一、CopyOnWriteArrayList介绍:
1.1)叫做写时拷贝,就是说我们在针对数组进行修改的时候,会创建出一个临时副本出来,假设我们是在多线程的环境下读ArrayList,是不会发生线程安全问题的,完全不需要进行加锁,也不需要其他方面的控制,如果说有多线程去写,我们就把这个ArrayList给复制一份,先进行修改副本
1.2)假设此时想要修改一个数组,array1里面的元素有{1,2,3,4},想把第一个元素修改成100,我们就会创建一个副本array2{100,2,3,4},修改完成之后,再让副本进行转正,原来的array1指向array2,这样做的好处就是,进行修改的同时,对于读操作是不会进行产生影响的,读的时候会优先读旧版本,不会说出现读到一个,修改了一半的中间状态
1.3适用于读多写少
1.CopyOnWriteArrayList,写数组的拷贝,支持高效率并发且是线程安全的,读操作无锁的ArrayList,所有可变操作都是通过对底层数组进行一次新的复制来实现。
2.CopyOnWriteArrayList适合使用在读操作远远大于写操作的场景里,比如缓存,它不存在扩容的概念,每次写操作都要复制一个副本,在副本的基础上修改后改变Array引用CopyOnWriteArrayList中写操作需要大面积复制数组,所以性能肯定很差
3.CopyOnWriteArrayList 合适读多写少的场景,不过这类慎用 ,因为谁也没法保证CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次add/set都要重新复制数组,这个代价实在太高昂了,在高性能的互联网应用中,这种操作分分钟引起故障
CopyOnWriteArrayList是一个随即写随进行复制的容器,也叫做双缓冲区的策略
1)当向容器中添加第一个元素的时候,不直接向当前容器中进行添加,而是先将当前容器进行复制操作,复制出一个新的容器,然后再向当前新的容器中进行添加,添加完容器之后,在将原来容器的引用指向新的容器
2)这样子做的好处就是说可以对CopyOnWriteArrayList进行并发程度的读,而不需要进行加锁,因为当前容器里面不会进行添加新的元素;
3)CopyOnWriteArrayList是一种读写分离的思想,读和写是使用着不同的容器
4)底层是用Reentrantlock来进行实现的
优点:
在我们进行读多写少的情况下,性能是很高的,不需要进行加锁的竞争
缺点:
1)占用内存比较多,频繁进行修改操作效率比较低或者说如果如果数组占用空间较大,你进行一次写时拷贝就会导致效率非常低;
2)新写的数据不能在第一时间被读取到;
在lock接口中获取锁的方法一共有四种:
1)Lock方法:是lock接口中最基础的获取锁的方法,有可用锁会直接获取到该锁并立即返回,当没有锁的时候会一直进行等待,直到获取到锁,就是一种死等机制;
2)lockInterruptibly方法:中途可以响应线程的中断
和Lock方法类似,当有锁的时候会直接得到该锁并进行返回,但是没有可用锁会一直阻塞等待获取到这把锁,但是如果遇到线程中断就会自动放弃这把锁,当调用thread.interrupt()方法的时候可以中断线程执行,此时lockInterrupteibly方法就会放弃获取锁
3)tryLock方法:使用无参的tryLock方法会尝试获取到这把锁,并立即返回获取到锁的结果,如果有可用锁就直接返回true,如果没有可用锁就直接返回false
4)tryLock(long time,TimeUnit unit)在指定时间内获取不到锁直接返回
第一个参数是long类型的超时时间,第二个参数是对参数1的时间单位的描述