在Java常见的容器中,比如ArrayList,HashMap,HashSet等都没有考虑到线程并发的安全问题,所以用来储存并发的线程是十分不安全的。例如用HashMap ,put去储存线程,产生死循环。会造成CPU使用率100%的严重后果。因为HashMap的Entry会产生环形链表的结构。你可能又会想到HashTabe这个容器,确实它是线程安全的,但是你深入HashTable的源码会发现,它的方法是用synchronized去修饰的,这样会造成在高并发线程(数量多)的情况下,效率会非常非常低。因为在synchronized修饰的原因下,put储存线程的同时,其它线程即不能put也不能get。
1.HashMap在高并发下为什么会产生死锁?
首先看下HashMap的put方法
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) { //374行
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
中,有一个e.next也就是指针往下移动,这里就很容易出现问题了。假如线程1是从1到2遍历,线程2是从2到1遍历,那么就会形成一个死锁的循环。
2.位运算知识点回顾
位与,位非,位或,位异或
在int型上,第31位上的是符号位,31位上为1则为负,31位上为0则为负
如果移动的位数超过了该类型的最大位数,那么编译器会对移动的位数取模。如对int型移动33位,实际上只移动了332=1位。
负数左移动低位补0,右移动高位补1
正数地位补0,高位也补0
a%b=a&(b-1)
在ConcurrentHashMap有大量的位运算操作,因为这样比纯粹的取模,乘除操作符号更快。
在程序设计上,权限基本都是采用了位运算的设计,这样的权限增删改查十分迅速。
public class Action {
//本质上就是给了4字节的二进制位置,1代表有,0代表没有,每个位置都有其所代表的权限
//分别赋予权限二进制参数
public static final int ALLOW_SELECT=1<<0; //0001
public static final int ALLOW_INSERT=1<<1; //0010
public static final int ALLOW_UPDATE=1<<2;//0100
public static final int ALLOW_DELETE=1<<3;//1000
//用于存放用户现在的权限
private int flag;
//增加权限的方法
public void add(int per){
//相当于做了加法
flag=flag|per;
}
//删除权限的方法
public void delete(int per){
//相当于做了减法
flag=flag&~per;
}
//判断是否有某个权限
public boolean isAllow(int per){
//相当于判断了第几个位置上是否存在1,存在也就代表有权限,全有也就1111
return (flag&per)==per;
}
//判断用户是否没有某个权限
public boolean isNotAllow(int per){
return (flag&per)==0;
}
}
位运算除了在计算上非常快,其在属性的替换上也非常迅捷,如果每个权限你都保存的是字符那么显得非常臃肿,造成了多个对象堆积在内存。位运算就显得十分必要,一个Int类型就可以代替至少32个属性。连JDK常用的public,private等等也都是通过位运算的方式。
3.ConcurrentHashMap
HashTable也是线程安全的,为什么在高并发情况下被弃用了呢?查看源码可以发现,HashTable运用的锁是sycn关键字,这就造成了,在某一时间段内只有一个锁享受对容器的操作权限,其余线程都处于等待状态。
ConncurrentHashMap如何解决在保证线程安全下,还能保证不影响高并发下的容器性能的呢?
它采用了锁分段技术,也就是将容器分为很多段,每个段都有锁,这样确保了每个段是线程安全的,但是在每个段操作的同时,并不影响其它线程对其它段的操作。
JDK1.7前实现原理图
JDK 1.7里Segment是不会扩容的,初始化会给一个大小,然后一直是这个大小,table在数据增多到限定容量的时候,是会进行扩容的,扩容后会进行再次的hashCode。也就是Rehash()方法。
查看源码不难发现Segment是一个ReentrantLock锁,它在完成类似数组索引任务的同时又去完成了一个分段锁的功能。这个索引去找到Map集合里的数组索引,这些数组索引又去指向键值对储存的Entry,当你使用put()方法的时候,SegmentShift和SegmentMask进行位运算,快速算的算出应该存往哪个segment段里。
JDK1.7里ConncurrentHashMap的get()方法是如何实现的?
(1)定位segement:key的hashCode进行再散列的高位 取模
(2)定位table:key的hashCode进行再散列 取模
(3)以此扫描链表,取出想要的值
JDK1.7里ConncurrentHashMap里的size()方法是如何实现的?
size()方法首先为了保证性能是不会去获取锁(获取锁会影响Put和get的执行),如果两次结果一致,那么返回结果。如果不一致会进行全面的segement加锁,停止所有的put和get操作。所以在日常开发过程中,尽可能不去使用size()方法,是十分影响性能的。
JDK1.7里ConncurrentHashMap是存在弱一致性的
也就说,在某一时间段内你put了一个值,但是此时你去get可能获取不到这个值。
JDK1.8 ConcurrentHashMap的实现
在JDK1.8中取消了1.7里的Segement,采用Node数组+链表+红黑树的设计,为了保证现成的安全采用的是CAS+Sycn关键字。所以锁的粒度更小。在解决散列冲突的办法中,将拉链法中的链表更新为了链表+红黑树。至于是链表还是红黑树,取决于数据量的大小。当数量超过8的时候,链表会转为红黑树。(链表的时间复杂度是O(N),红黑树的时间复杂度是O(logN),在效率上更快,但是当数据量较小的时候就不值得用红黑树,因为红黑树的实现较为复杂,在元素的插入所需时间上,远远超过链表)
JDK1.7中key与Value的存放交由HashEntry,在JDK1.8中,key与value的存放交给了Node类。
当使用红黑树后,会变成Node的子类TreeNode类去储存。
3.ConcurrentHashMap
查看源码发现,ConcurrentHashMap和HashMap都是继承于AbstractMao类,所以在部分方法上还是大同小异的,但是ConncurrentHashMap增加了几个非常实用的方法,例如:putifAbsent()这个方法主要是表示,存放的时候查看散列里是否已经存在,如果存在,则不再次存入,使用旧的去充当返回结果。
4.ConcurrentSkipListMap和ConcurrentSkipListSet
ConncurrentSkipListMap和ConcurrentSkipListSet是TreeMap和TreeSet的线程安全并发版本,在JDK1.8后将其从红黑树改为了跳表结构。
什么是跳表呢?理论上红黑树的时间复杂度更低,为什么要切换跳表结构呢?
跳表是一种空间换时间的结构,内存利用率极低,一般为10%左右。其设计思路是建立多重索引,索引的粒度是越往下越小的。以达到快速定位查找的数所在位置的目的。在Redis中索引就是采用了跳表结构。
理论上红黑树的时间复杂度更低,为什么还要采用跳表结构呢?(1)红黑树的实现极其复杂,包括左旋右旋等。而跳表的实现简单。(2)红黑树每次增加删除都可能会打乱树的结构,需要再重新进行构造,而跳表直接进行更改头尾指针就可以。(3)红黑树一个节点存在两个指针,跳表的每个节点指针数目为(1/1-p)。跟参数相关,在参数低的情况下指针数目是少于红黑树的,例如redis参数为1/4。每个节点含有的节点个数是1.35。
5.写时复制容器 CopyOnWriteArrayList和CopyOnWriteArraySet
写时复制容器,当往容器写入数据的时候,首先会进行复制一份,在复制的那份中进行写入。读还是在原始容器。当写入完成后,读的指针地址会变更为指向写。但写时复制容器一致性差,只能保证最终一致性,因为在最终前,读和写是两个容器。所以无法保证实时一致性。
在现实应用中,主要运用于读多写少的容器。例如企业常常应用于白名单和黑名单。在每个应用中都会有关键词是不允许你进行搜索和发表的。这些不允许搜索的关键字就会放在一个黑名单里,这个关键词不是常常需要更新写入的。但是需要实时去读。所以用写时复制容器。
优点:(1)读写分离。
缺点:(1)占用内存,因为每次写入都要复制一下。(2)数据实时一致性无法保证。
读写锁和写时复制的性能?
写时复制的时间复杂度更低,是一种空间换时间的思想。读和写是俩个内存空间是互相不影响的。读写锁是在一个空间,读锁获取时,写锁必须等待。写锁获取时读锁必须等待。是互斥的。所以性能不如写时复制。关于读写锁的详解,笔者推荐https://www.cnblogs.com/xiaoxi/p/9140541.html
6.阻塞队列
阻塞队列,当队列为空的时候阻塞取的线程,当队列满的时候,阻塞存的线程。
阻塞队列常见于生产者-消费者模式,这种模式下存在的问题是,消费者和生产者的速率不匹配,那么为了一个它们在一个速率上,就可以让俩者通过阻塞队列去传递数据。这样保证生产快了会被阻塞,消费的快了也会被阻塞。
常用方法
JDK中JDK为我们提供了实现阻塞队列的标准接口BlockingQueue
具体的实现类有
有界和无界的定义?
有界和无界是相对于是否给链表或数组的长度设定大小。无界只是说不设置大小,但其也会收到计算机硬件性能的限制。
LinkedBlockingQueue和LinkedTransferQueue都是链表实现的阻塞队列,那么它们除了有界和无界外还有什么区别呢?
LinkedTransferQueue多了tranfer()和trytranfer()方法,这俩个方法主要作用在于,消费者生产了可以直接给生产者。它俩的区别在于tranfer()必须消费者消费了才可以返回值。trytranfer()无论消费者是否消费,立即返回。
SynchronousQueue不存储元素?
SynchprnousQueue不存储元素,生产一个元素,必须有消费者去消费。
LinkedBlockingDeque
LinkedBlockingDeque是个双向链表,可以用来实现工作密取,头尾都可以插入和删除,带有frist的方法表示头操作,带有last表示尾操作。如果不加 add默认addlast,remove默认为removefrist。
DelayQueue是一个支持延时获取元素的无界阻塞队列。队列使用PriorityQueue来实现。队列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。
DelayQueue在企业开发中,常用于订单限时支付的场景,那么我们写一个小代码,来实现一下延时队列的功能
(1)实现Delayed.class
package Delayed;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;
/**
* 类:用来存放到队列的元素
* @param <T>
*/
public class ItemVo<T> implements Delayed {
private long activeTime;
//队列传入的数据
private T date;
public ItemVo(long activeTime,T date){
super();
//因为Delayed内都是以纳秒为单位,所以需要进行转换
//将用户传入的超时时间转为超时的时刻(纳秒)
this.activeTime=TimeUnit.NANOSECONDS.convert
(activeTime,TimeUnit.MILLISECONDS)+System.nanoTime();
this.date=date;
}
//getDelay是Delayed接口中需要实现的方法
//作用是返回离超时时刻还剩余的时间
@Override
public long getDelay(TimeUnit unit) {
//让超时的时刻减去当前时刻
long d=unit.convert(this.activeTime-System.nanoTime(),
TimeUnit.NANOSECONDS);
return d;
}
//compareTo比较方法,是Delayed接口继承CompareTo接口所要实现的
//作用是按照剩余时间进行排序
@Override
public int compareTo(Delayed o) {
long d=getDelay(TimeUnit.NANOSECONDS)-o.getDelay(TimeUnit.NANOSECONDS);
return (d==0)?0:(d>0?1:-1);
}
public T getDate() {
return date;
}
}
(2)订单的实体类
package Delayed;
public class Order {
//订单的编号
private String orderNo;
//订单的价格
private Double orderMoney;
public Order(String orderNo,Double orderMoney){
super();
this.orderNo=orderNo;
this.orderMoney=orderMoney;
}
public String getOrderNo() {
return orderNo;
}
public Double getOrderMoney() {
return orderMoney;
}
}
(3)存放订单信息的线程.class
package Delayed;
import java.util.concurrent.DelayQueue;
/**
* 把订单加入队列的线程
*/
public class PutOrder implements Runnable {
private DelayQueue<ItemVo<Order>> queue;
public PutOrder(DelayQueue queue){
this.queue=queue;
}
@Override
public void run() {
//床架订单类
Order JDorder=new Order("No666",777.0);
//创建Delay类,延时500ms,date为Order类
ItemVo<Order> itemVo=new ItemVo<>(5000,JDorder);
//将Delay类放入延时队列
queue.offer(itemVo);
System.out.println(JDorder.getOrderNo()+"订单 5000ms后会进入可见状态");
}
}
(4)取出订单的线程.class
package Delayed;
import java.util.concurrent.DelayQueue;
public class GetOrder implements Runnable {
private DelayQueue<ItemVo<Order>> queue;
public GetOrder(DelayQueue queue){
this.queue=queue;
}
@Override
public void run() {
try {
ItemVo<Order> itemVo=queue.take();
Order order=itemVo.getDate();
System.out.println(order.getOrderNo() +"已被获取");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
(5)测试队列功能.class
package Delayed;
import java.util.concurrent.DelayQueue;
public class Test {
public static void main(String[] args) throws InterruptedException {
//创建队列
DelayQueue<ItemVo<Order>> queue=new DelayQueue<>();
//让存队列开启
new Thread(new PutOrder(queue)).start();
//让取队列开启
new Thread(new GetOrder(queue)).start();
//为了更清晰的观察时间,让线程每隔500ms取输出一下
for (int i=0;i<15;i++){
Thread.sleep(500);
System.out.println((i+1)*500+"ms");
}
}
}