Java基础
数组(Array)与列表(ArrayList)的区别
空间大小:
Array的空间大小是固定的,空间不够时也不能再次申请,所以需要事前确定合适的空间大小。
ArrayList的空间是动态增长的,如果空间不够,它会创建一个空间比原空间大0.5倍的新数组,然后将所有元素复制到新数组中,接着抛弃旧数组。而且,每次添加新的元素的时候都会检查内部数组的空间是否足够。
存储内容:
Array数组可以包含基本类型和对象类型
ArrayList却只能包含对象类型
方法:
ArrayList作为Array的增强版,方法上比Array多样化。比如添加全部addAll()、删除全部removeAll()、返回迭代器iterator()等。
mybatis中#与$的区别
MyBatis中使用parameterType向SQL语句传参,parameterType支持的类型可以是基本类型int,String,HashMap和java自定义类型。
在SQL中引用这些参数的时候,可以使用两种方式:
‘#{parameterName}’
‘${parameterName}’
- 使用#{parameterName}引用参数的时候,Mybatis会把这个参数认为是一个字符串,并自动加上’’,例如传入参数是“Smith”,那么在下面SQL中:
Select * from emp where name = ‘Smith’; - 使用${parameterName},会转换成:
Select * from emp where name = Smith;
简单说#{}是经过预编译的,是安全的。
而${}是未经过预编译的,仅仅是取变量的值,是非安全的,存在SQL注入。
内部类
外部类按常规的类访问方式使用内部类,唯一的差别是外部类可以访问内部 类的所有方法与属性, 包括私有方法与属性。
内部类创建实例 OutClass.InnerClass obj = outClassInstance.new InnerClass(); //注意是外部类实例.new,内部类
静态内部类创建实例 AAA.StaticInner in = new AAA.StaticInner();//注意是外部类本身,静态内部类
为什么要用内部类?
- 内部类⼀般只为其外部类使用
- 内部类提供了某种进入外部类的窗户
- 也是最吸引人的原因,每个内部类都能独立地继承⼀个类,而无论外部类是否已经继承了某个类。 因此,内部类使多重继承的解决方案变得更加完整。
泛型
泛型的本质是为了参数化类型(在不创建新的类型的情况下,通过泛型指定的不同类型来控制 形参具体限制的类型)。也就是说在泛型使⽤过程中,操作的数据类型被指定为⼀个参数,这种参数类 型可以⽤在类、接⼝和⽅法中,分别被称为泛型类、泛型接⼝、泛型⽅法。
例子:
List arrayList = new ArrayList();
arrayList.add("aaaa");
arrayList.add(100);
for(int i = 0; i< arrayList.size();i++){
String item = (String)arrayList.get(i);
Log.d("泛型测试","item = " + item);
}
ArrayList可以存放任意类型,例⼦中添加了⼀个String类型,添加了⼀个Integer类型,再使⽤时都以 String的⽅式使⽤,因此程序崩溃了。为了解决类似这样的问题(在编译阶段就可以解决),泛型应运⽽⽣。
List
Java中的泛型,只在编译阶段有效。在编译过程中,正确检验泛型结果后,会将泛型的相关信息擦出,并且在对象进⼊和离开⽅法的 边界处添加类型检查和类型转换的⽅法。也就是说,泛型信息不会进⼊到运⾏时阶段。
泛型类型在逻辑上看以看成是多个不同的类型,实际上都是相同的基本类型。
Java finally语句到底是在return之前还 是之后执行?
以下情况finally语句不会执行:
- 执行到try语句块之前就return,当然不会执行finally
- 在try块中有System.exit(0);这样的语句,System.exit(0);是终⽌Java虚拟机JVM的,连JVM都停⽌ 了,所有都结束了,当然finally语句也不会被执⾏到。
finally语句在return语句执⾏之后return返回之前执⾏的。
就是return语句已经在try执⾏了再去执⾏finally语句,不过并没有直接返回,⽽是等finally语句执⾏完了再返回结果。
另外,如果finally里面也有return,那么就直接返回,覆盖掉try里面的return
然后如果finally里面不使用return,而是修改try里面return返回的值。那么返回的是修改之前,还是修改之后?
这取决于Java传值还是传址,传值就不会更改,传址就会更改。
那么如果异常,不执行try,执行catch,catch里面也有return,那么这里的return和try里的性质一样,执行了再去执行finally,也没有直接返回,而是等finally执行完在返回结果。
Java对象初始化过程
Person jack = new Person();
- new ⽤到了Person.class,所以会先找到Person.class⽂件,并加载到内存中(⽤到类中的内容类就会被加载)
- 执⾏该对象的static代码块(静态初始块)。(如果有的话,给Person.class类进⾏初始化)
- 在堆内存中开辟空间,分配内存地址
- 在堆内存中建⽴对象特有属性,并进⾏默认初始化
- 对属性进⾏显示初始化(声明成员属性并赋值)
- 执⾏普通初始块
- 执⾏构造函数
- 将内存地址赋值给栈内存中的jack变量
面向接口编程
⾯向接⼝编程和⾯向对象编程并不是平级的,它并不是⽐⾯向对象编程更先进的⼀种独⽴的编程思想,⽽是附属于面向对象思想体系,属于其⼀部分。或者说,它是⾯向对象编程体系中的思想精髓之⼀。
接⼝是⼀组规则的集合,它规定了实现本接⼝的类或接⼝必须拥有的⼀ 组规则。
接⼝是在⼀定粒度视图上同类事物的抽象表示。
⾯向对象的精髓是模拟现实,多从现实中思考⾯向对象的东⻄,对提⾼系统分析设计能⼒⼤有脾益。
单从具体功能来看, 除多重继承外,抽象类似乎完全能取代接⼝。但是,难道接⼝的存在是为了实现多重继承?当然不是。抽象类和接⼝的区别在于使⽤动机。使⽤抽象类是为了代码的复⽤,⽽使⽤接⼝的动机是为了实现多态性。
ArrayList
ArrayList继承⾃ AbstractList,实现了 List 接⼝。底层基于数组实现容量⼤⼩动态变化。允许 null 的存在。同时还实现了 RandomAccess、Cloneable、 Serializable 接⼝,所以ArrayList 是⽀持快速访问、复制、序列化的。
- ArrayList是内部是以动态数组的形式来存储数据的、这⾥的动态数组不是意味着去改变原有内部⽣成的数组的⻓度、⽽是保留原有数组的引⽤、将其指向新 ⽣成的数组对象、这样会造成数组的⻓度可变的假象。
- ArrayList具有数组所具有的特性、通过索引⽀持随机访问、所以通过随机访问ArrayList中的元素 效率⾮常⾼、但是执⾏插⼊、删除时效率⽐较地下。
- ArrayList实现了AbstractList抽象类、List接⼝、所以其更具有了AbstractList和List的功能、前⾯我们知道AbstractList内部已经实现了获取Iterator和ListIterator的⽅法、所以ArrayList只需关⼼对数组操作的⽅法的实现
- ArrayList实现了RandomAccess接⼝、此接⼝只有声明、没有⽅法体、表示ArrayList⽀持随机访问。
- ArrayList实现了Cloneable接⼝、此接⼝只有声明、没有⽅法体、表示ArrayList⽀持克隆。
- ArrayList实现了Serializable接⼝、此接⼝只有声明、没有⽅法体、表示ArrayList⽀持序列化、即 可以将ArrayList以流的形式通过ObjectInputStream/ObjectOutputStream来写/读。
remove:
在调⽤remove()⽅法时List的⻓度会发⽣变化⽽且元素 的位置会发⽣移动,从⽽在遍历时list实际上是变化的。最终达不到效果。//arrayList中的值为 [a,a,c,a,a] for (int i = 0; i < arrayList.size(); i++) { if (arrayList.get(i) == "a") { arrayList.remove(i); } } System.out.println(arrayList);
解决://逆向遍历 for (int i = arrayList.size()-1; i >=0 ; i--) { if (arrayList.get(i) == "a") { arrayList.remove(i); } } System.out.println(arrayList); //迭代器 Iterator<String> ite = arrayList.listIterator(); while (ite.hasNext()){ if(ite.next() == "a") ite.remove(); } System.out.println(arrayList);
HashMap
Map 是 Key-Value 对映射的抽象接⼝,该映射不包括重复的键,即⼀个键对应⼀个值。HashMap 是基于哈希表的 Map 接⼝的实现,以 Key-Value 的形式存在,即存储的对象是 Entry (同时 包含了 Key 和 Value) 。在HashMap中,其会根据hash算法来计算key-value的存储位置并进⾏快速存取。特别地,HashMap最多只允许⼀条Entry的键为Null(多条会覆盖),但允许多条Entry的值为Null。此 外,HashMap 是 Map 的⼀个⾮同步的实现。
需要注意,虽然容器号称存储的是 Java 对象,但实际上并不会真正将 Java 对象放⼊容器中,只是在容器中保留这些对象的引⽤。也就是说,Java 容器实际上包含的是引⽤变量,⽽这些引⽤变量指向了我们要实际保存的 Java 对象。
HashMap<String, Integer> map = new HashMap<String, Integer>();
map.put("语文",1);
map.put("数学",2);
map.put("化学",3 );
put的实现:
- 对key的hashCode()做hash,然后再计算index;
- 如果没碰撞直接放到bucket⾥;
- 如果碰撞了,以链表的形式存在buckets后;
- 如果碰撞导致链表过⻓(⼤于等于TREEIFY_THRESHOLD),就把链表转换成红⿊树;
- 如果节点已经存在就替换old value(保证key的唯⼀性)
- 如果bucket满了(超过load factor*current capacity),就要resize。
get的实现:
- bucket⾥的第⼀个节点,直接命中;
- 如果有冲突,则通过key.equals(k)去查找对应的entry
- 若为树,则在树中通过key.equals(k)查找,O(logn);
- 若为链表,则在链表中通过key.equals(k)查找,O(n)。
HashMap的⼯作原理:
通过hash的⽅法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put⽅法时,它调⽤ hashCode计算hash从⽽得到bucket位置,进⼀步存储,HashMap会根据当前bucket的占⽤情况⾃动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调⽤hashCode计算 hash从⽽得到bucket位置,并进⼀步调⽤equals()⽅法确定键值对。如果发⽣碰撞的时候,Hashmap通 过链表将产⽣碰撞冲突的元素组织起来,在Java 8中,如果⼀个bucket中碰撞冲突的元素超过某个限制 (默认是8),则使⽤红⿊树来替换链表,从⽽提⾼速度。
哈希表每次增删改查的代价可以说是O(1),当有hash冲突时,会在后面建立单链表,这时插入和查找以及删除操作消耗的时间会达到O(n)。每次扩容的代价是O(logn)
equals()和hashCode()的都有什么作用?
通过对key的hashCode()进⾏hashing,并计算下标( n-1 & hash),从⽽获得buckets的位置。如果产⽣ 碰撞,则利⽤key.equals()⽅法去链表或树中去查找对应的节点
hash的实现吗?为什么要这样实现?
在Java 1.8的实现中,是通过hashCode()的⾼16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在bucket的n⽐较⼩的时候,也能保证考虑到⾼低 bit都参与到hash的计算中,同时不会有太⼤的开销。
IO
字节流和字符流的区别:
读写单位不同:字节流以字节(8bit)为单位,字符流以字符为单位,根据码表映射字符,⼀次可能读多个字节。
处理对象不同:字节流能处理所有类型的数据(如图片、avi等),而字符流只能处理字符类型的数据。
只要是处理纯⽂本数据,就优先考虑使⽤字符流。 除此之外都使⽤字节流。
NIO
NIO主要有三⼤核⼼部分:Channel(通道),Buffer(缓冲区), Selector。传统IO基于字节流和字符流进⾏ 操作,⽽NIO基于Channel和Buffer(缓冲区)进⾏操作,数据总是从通道读取到缓冲区中,或者从缓冲区写 ⼊到通道中。Selector(选择区)⽤于监听多个通道的事件(⽐如:连接打开,数据到达)。因此,单个线程 可以监听多个数据通道。
NIO 和 IO:
IO是⾯向流的,NIO是⾯向缓冲区的。 Java IO⾯向流意味着每次从流中读⼀个或多个字节,直⾄读取所有字节,它们没有被缓存在任何地⽅。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到⼀个缓冲区。NIO的缓冲导向⽅法略有不同。数据读取到⼀个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。⽽且,需确保当更多的数据读⼊缓冲区时,不要覆盖缓冲区⾥尚未处理的数据。
IO的各种流是阻塞的。这意味着,当⼀个线程调⽤read() 或 write()时,该线程被阻塞,直到有⼀些数据 被读取,或数据完全写⼊。该线程在此期间不能再⼲任何事情了。 NIO的⾮阻塞模式,使⼀个线程从某通 道发送请求读取数据,但是它仅能得到⽬前可⽤的数据,如果⽬前没有数据可⽤时,就什么都不会获取。⽽不是保持线程阻塞,所以直⾄数据变的可以读取之前,该线程可以继续做其他的事情。线程通常将⾮阻塞IO的空闲时间⽤于在其它通道上执⾏IO操作,所以⼀个单独的线程现在可以管理多个输 ⼊和输出通道(channel)。
Channel:
Channel和IO中的Stream(流)是差不多⼀个等级的。只不 过Stream是单向的,譬如:InputStream, OutputStream.⽽Channel是双向的,既可以⽤来进⾏读操作,⼜可以⽤来进⾏写操作。
NIO中的Channel的主要实现有: FileChannel DatagramChannel SocketChannel ServerSocketChannel
Buffer:
NIO中的关键Buffer实现有:ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer,分别对应基本数据类型: byte, char, double, float, int, long, short
Selector:
Selector运⾏单线程处理多个Channel,如果你的应⽤打开了多个通道,但每个连接的流量都很低,使⽤ Selector就会很⽅便。例如在⼀个聊天服务器中。要使⽤Selector, 得向Selector注册Channel,然后调⽤ 它的select()⽅法。这个⽅法会⼀直阻塞到某个注册的通道有事件就绪。⼀旦这个⽅法返回,线程就可以处 理这些事件,事件的例⼦有如新的连接进来、数据接收等。
Buffer步骤:
分配空间(ByteBuffer buf = ByteBuffer.allocate(1024); 还有⼀种allocateDirector后⾯再陈述)
写⼊数据到Buffer(int bytesRead = fileChannel.read(buf);)
调⽤filp()⽅法( buf.flip();)
从Buffer中读取数据(System.out.print((char)buf.get());)
调⽤clear()⽅法或者compact()⽅法
进程线程
现在的操作系统是多任务操作系统。多线程是实现多任务的⼀种⽅式。
进程是指⼀个内存中运⾏的应⽤程序,每个进程都有⾃⼰独⽴的⼀块内存空间,⼀个进程中可以启动多个 线程。⽐如在Windows系统中,⼀个运⾏的exe就是⼀个进程。
线程是指进程中的⼀个执⾏流程,⼀个进程中可以运⾏多个线程。⽐如java.exe进程中可以运⾏很多线 程。线程总是属于某个进程,进程中的多个线程共享进程的内存。
在java中要想实现多线程,有两种⼿段,⼀种是继续Thread类,另外⼀种是实现Runable接⼝,当然准确来讲还有实现Callable接⼝,并与Future、线程池结合使⽤
Java线程状态:
线程的⽣命周期及五种基本状态:
- 新建状态(New):当线程对象对创建后,即进⼊了新建状态,如:Thread t = new MyThread();
- 就绪状态(Runnable):当调⽤线程对象的start()⽅法(t.start();),线程即进⼊就绪状态。处于就绪状 态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执⾏,并不是说执⾏了t.start()此线程⽴ 即就会执⾏;
- 运⾏状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执⾏,即进⼊到运⾏状态。注:就绪状态是进⼊到运⾏状态的唯⼀⼊⼝,也就是说,线程要想进⼊运⾏状态执⾏,⾸先必须处于就绪状态中;
- 阻塞状态(Blocked):处于运⾏状态中的线程由于某种原因,暂时放弃对CPU的使⽤权,停⽌执⾏,此时进⼊阻塞状态,直到其进⼊到就绪状态,才有机会再次被CPU调⽤以进⼊到运⾏状态。根据阻塞产⽣的 原因不同,阻塞状态⼜可以分为三种:
- 等待阻塞: 运⾏状态中的线程执⾏wait()⽅法,使本线程进⼊到等待阻塞状态;
- 同步阻塞: 线程在获取synchronized同步锁失败(因为锁被其它线程所占⽤),它会进⼊同步阻塞状态;
- 其他阻塞: 通过调⽤线程的sleep()或join()或发出了I/O请求时,线程会进⼊到阻塞状态。当 sleep()状态超时、join()等待线程终⽌或者超时、或者I/O处理完毕时,线程重新转⼊就绪状态。
- 死亡状态(Dead):线程执⾏完了或者因异常退出了run()⽅法,该线程结束⽣命周期。
常用线程函数:
sleep: 在指定的毫秒数内让当前正在执⾏的线程休眠(暂停执⾏)
join: 指等待t线程终⽌。即join()的作⽤是:“等待该线程终⽌”,这⾥需要理解的就是该线程是指的主线程等待⼦线程的终⽌。也就是在⼦线程调⽤了join()⽅法后⾯的代码,只有等到⼦线程结束了才能执⾏。
yield():暂停当前正在执⾏的线程对象,并执⾏其他线程。yield()应该做的是让当前运⾏线程回到可运⾏状态,以允许具有相同优先级的其他线程获得运⾏机会。
setPriority(): 更改线程的优先级, - MIN_PRIORITY = 1
- NORM_PRIORITY = 5
- MAX_PRIORITY = 10
interrupt():不要以为它是中断某个线程!它只是向线程发送⼀个中断信号,让线程在⽆限等待时(如死锁时)能抛出,从⽽结束线程,但是如果你吃掉了这个异常,那么这个线程还是不会中断的!
wait():wait就是说线程在获取对象锁后,主动释放对象锁,同时本线程休眠。直到有其它线程调⽤对象的notify()唤醒该线程,才能继续获取对象锁,并继续执⾏。
Thread.sleep()与Object.wait()⼆者都可以暂停当前线程,释放CPU控制权,主要的区别在于Object.wait()在释放CPU同时,释放了对象锁的控制。
wait和sleep区别:
相同点:
- 他们都是在多线程的环境下,都可以在程序的调⽤处阻塞指定的毫秒数,并返回。
- wait()和sleep()都可以通过interrupt()⽅法 打断线程的暂停状态 ,从⽽使线程⽴刻抛出 InterruptedException。如果线程A希望⽴即结束线程B,则可以对线程B对应的Thread实例调⽤interrupt⽅法。如果此刻 线程B正在wait/sleep /join,则线程B会⽴刻抛出InterruptedException,在catch() {} 中直接 return即可安全地结束线程。需要注意的是,InterruptedException是线程⾃⼰从内部抛出的,并不是interrupt()⽅法抛出的。 对某⼀线程调⽤ interrupt()时,如果该线程正在执⾏普通的代码,那么该线程根本就不会抛出 InterruptedException。
不同点: - Thread类的⽅法:sleep(),yield()等
- Object的⽅法:wait()和notify()等
- 每个对象都有⼀个锁来控制同步访问。Synchronized关键字可以和对象的锁交互,来实现线程的 同步。sleep⽅法没有释放锁,⽽wait方法释放了锁,使得其他线程可以使⽤同步控制块或者⽅法。
- wait,notify和notifyAll只能在同步控制方法或者同步控制块里面使用,而sleep可以在任何地方使用
sleep():
- sleep()使当前线程进⼊停滞状态(阻塞当前线程),让出CUP的使用、目的是不让当前线程独⾃霸占 该进程所获的CPU资源,以留⼀定时间给其他线程执⾏的机会;
- sleep()是Thread类的Static(静态)的⽅法;因此他不能改变对象的机锁,所以当在⼀个Synchronized 块中调⽤Sleep()⽅法是,线程虽然休眠了,但是对象的机锁并⽊有被释放,其他线程⽆法访问这个对 象(即使睡着也持有对象锁)。
- 在sleep()休眠时间期满后,该线程不⼀定会立即执行,这是因为其它线程可能正在运⾏,除非此线程具有更高的优先级。
wait(): - wait()⽅法是Object类⾥的方法;当⼀个线程执行到wait()方法时,它就进入到⼀个和该对象相关的等 待池中,同时失去(释放)了对象的机锁(暂时失去机锁,wait(long timeout)超时时间到后还需要返 还对象锁);
- wait()使⽤notify或者notifyAlll或者指定睡眠时间来唤醒当前等待池中的线程。
- wiat()必须放在synchronized block中,否则会在program runtime时扔 出”java.lang.IllegalMonitorStateException“异常。
悲观锁与乐观锁:
悲观锁:
cpu是时分复用的,也就是把cpu的时间片,分配给不同的thread/process轮流执行,时间片与时间片之间,需要进行cpu切换,也就是会发生进程的切换。
切换涉及到清空寄存器,缓存数据。然后重新加载新的thread所需数据。当⼀个线程被挂起时,加⼊到阻塞队列,再通过 notify(),notifyAll()唤醒回来。在某个资源不可⽤的时候,就将cpu让出,把当前等待线程切换为阻塞状态。等到资源(⽐如⼀个共享数据)可⽤了,那么就将线程唤醒,让他进⼊runnable状态等待cpu调度。
独占锁是⼀种悲观锁,synchronized就是⼀种独占锁,它假设最坏的情况,并 且只有在确保其它线程不会造成⼲扰的情况下执⾏,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。
悲观锁缺点:
由于在进程挂起和恢复执行过程中存在着很大的开销。当⼀个线程正在等待锁时,它不能做任何事,所以悲观锁有很大的缺点。举个例子,如果⼀个线程需要某个资源,但是这个资源的占⽤时间很短, 当线程第⼀次抢占这个资源时,可能这个资源被占⽤,如果此时挂起这个线程,可能⽴刻就发现资源可用,然后又需要花费很⻓的时间重新抢占锁,时间代价就会非常高。
乐观锁:它的核心思路就是,每次不加锁而是假设没有冲突⽽去完成某项操作,如果因 为冲突失败就重试,直到成功为⽌。
在上面的例子中,某个线程可以不让出cpu,而是⼀直while循环,如果 失败就重试,直到成功为止。所以,当数据争⽤不严重时,乐观锁效果更好。⽐如CAS就是⼀种乐观锁思想的应⽤。
Java中CAS实现
CAS就是Compare and Swap的意思,⽐较并操作。很多的cpu直接⽀持CAS指令。CAS是项乐观锁技术,当多个线程尝试使⽤CAS同时更新同⼀个变量时,只有其中⼀个线程能更新变量的值,⽽其它线程都 失败,失败的线程并不会被挂起,⽽是被告知这次竞争中失败,并可以再次尝试。CAS有3个操作数,内存 值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么 都不做。
在 java.util.concurrent.atomic包下⾯的所有的原⼦变量类型中,⽐如AtomicInteger,都使⽤了这些底层的 JVM⽀持为数字类型的引⽤类型提供⼀种⾼效的CAS操作。
在CAS操作中,会出现ABA问题。就是如果V的值先由A变成B,再由B变成A,那么仍然认为是发⽣了变 化,并需要重新执⾏算法中的步骤。有简单的解决⽅案:不是更新某个引⽤的值,⽽是更新两个值,包括 ⼀个引⽤和⼀个版本号,即使这个值由A变为B,然后为变为A,版本号也是不同的。
AtomicInteger的实现
AtomicInteger 是⼀个⽀持原⼦操作的 Integer 类,就是保证对AtomicInteger类型变量的增加和减少操 作是原⼦性的,不会出现多个线程下的数据不⼀致问题。如果不使⽤ AtomicInteger,要实现⼀个按顺序 获取的 ID,就必须在每次获取时进⾏加锁操作,以避免出现并发时获取到同样的 ID 的现象。
Java8基于CAS的ConcurrentHashMap
Java 8为进⼀步提高并发性,摒弃了分段锁的方案,而是直接使用一个大的数组。同时为了提高哈希碰撞下的寻址性能,Java 8在链表⻓度超过⼀定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(log(N)))。其数据结构如下图所示:
寻址方式
Java 8的ConcurrentHashMap同样是通过Key的哈希值与数组⻓度取模确定该Key在数组中的索引。同样为了避免不太好的Key的hashCode设计,它通过如下⽅法计算得到Key的最终哈希值。Java 8 的ConcurrentHashMap作者认为引⼊红⿊树后,即使哈希冲突⽐较严重,寻址效率也⾜够⾼,所以作者并未在哈希值的计算上做过多设计,只是将Key的hashCode值与其⾼16位作异或并保证最⾼位为0(从⽽保证最终结果为正整数)。
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
同步方式
put操作:如果Key对应的数组元素为null,则通过CAS操作将其设置为当前值。如果Key对应的数组 元素(也即链表表头或者树的根元素)不为null,则对该元素使⽤synchronized关键字申请锁,然后进行操作。如果该put操作使得当前链表⻓度超过⼀定阈值,则将该链表转换为树,提高寻址效率。
读操作:由于数组被volatile关键字修饰,因此不⽤担⼼数组的可见性问题。同时每个元素是⼀个Node实例(Java 7中每个元素是⼀个HashEntry),它的Key值和hash值都由final修饰,不可变更,⽆须关⼼它们被修改后的可见性问题。⽽其Value及对下⼀个元素的引⽤由volatile修饰,可见性也有保障。
size操作:put⽅法和remove⽅法都会通过addCount⽅法维护Map的size。size⽅法通过sumCount获取由 addCount⽅法维护的Map的size。
Java并发:CopyOnWriteArrayList实现原理
CopyOnWriteArrayList是Java并发包中提供的⼀个并发容器,它是个线程安全且读操作⽆锁的 ArrayList,写操作则通过创建底层数组的新副本来实现,是⼀种读写分离的并发策略。
实现原理:
ArrayList是⾮线程安全的,Vector虽是线程安全的,但由于简单粗暴的锁同步 机制,性能较差。⽽CopyOnWriteArrayList则提供了另⼀种不同的并发处理策略:
CopyOnWriteArrayList容器允许并发读,读操作 是⽆锁的,性能较⾼。⾄于写操作,⽐如向容器中添加⼀个元素,则⾸先将当前容器复制⼀份,然后在新 副本上执⾏写操作,结束之后再将原容器的引⽤指向新容器。
优点:读操作性能很⾼,因为⽆需任何同步措施,⽐较适⽤于读多写少的并发场景。
缺点:
- 内存占⽤问题,毕竟每次执⾏写操作都要将原容器拷⻉⼀份,数据量⼤时,对内存压 ⼒较⼤,可能会引起频繁GC。
- ⽆法保证实时性,Vector对于读写操作均加锁同步,可以保证读和写的 强⼀致性。⽽CopyOnWriteArrayList由于其实现策略的原因,写和读分别作⽤在新⽼不同容器上,读的时候是老容器的数据。
CAS详解
在JDK 5之前Java是靠synchronized关键字保证同步的,这会导致有锁。
- 锁机制的问题
- 在多线程竞争下,加锁、释放锁会导致⽐较多的上下⽂切换和调度延时,引起性能问题。
- ⼀个线程持有锁会导致其它所有需要此锁的线程挂起。
- 如果⼀个优先级⾼的线程等待⼀个优先级低的线程释放锁会导致优先级倒置,引起性能⻛险。
volatile是不错的机制,但是volatile不能保证原⼦性。因此对于同步最终还是要回到锁机制上来。
什么是CAS
在Java发展初期,java语⾔是不能够利⽤硬件提供的这些便利来提升系统的性能的。⽽随着java不断的发 展,Java本地⽅法(JNI)的出现,使得java程序越过JVM直接调⽤本地⽅法提供了⼀种便捷的⽅式,因⽽java 在并发的⼿段上也多了起来。⽽在Doug Lea提供的cucurenct包中,CAS理论是它实现整个java包的基 ⽯。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原 值相匹配,那么处理器会⾃动将该位置值更新为新值 。否则,处理器不做任何操作。⽆论哪种情况,它都 会在 CAS 指令之前返回该 位置的值。(在 CAS 的⼀些特殊情况下将仅返回 CAS 是否成功,⽽不提取当 前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否 则,不要更改该位置,只告诉我这个位置现在的值即可。
通常将 CAS ⽤于同步的⽅式是从地址 V 读取值 A,执⾏多步计算来获得新 值 B,然后使⽤ CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。
类似于 CAS 的指令允许算法执⾏读-修改-写操作,⽽⽆需害怕其他线程同时修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法可以对该操作重新计算。
CAS的⽬的
利⽤CPU的CAS指令,同时借助JNI来完成Java的⾮阻塞算法。其它原⼦操作都是利⽤类似的特性完成的。
CAS存在的问题
- ABA问题:因为CAS需要在操作值的时候检查下值有没有发⽣变化,如果没有发⽣变化则更新,但是如 果⼀个值原来是A,变成了B,⼜变成了A,那么使⽤CAS进⾏检查时会发现它的值没有发⽣变化,但是实 际上却变化了。ABA问题的解决思路就是使⽤版本号。在变量前⾯追加上版本号,每次变量更新的时候把 版本号加⼀,那么A-B-A 就会变成1A-2B-3A。
- 循环时间⻓开销大:自旋CAS如果⻓时间不成功,会给CPU带来⾮常⼤的执⾏开销。如果JVM能⽀持处 理器提供的pause指令那么效率会有⼀定的提升,pause指令有两个作⽤,第⼀它可以延迟流⽔线执⾏指令 (de-pipeline),使CPU不会消耗过多的执⾏资源,延迟的时间取决于具体实现的版本,在⼀些处理器上 延迟时间是零。第⼆它可以避免在退出循环的时候因内存顺序冲突(memory order violation)⽽引起 CPU流⽔线被清空(CPU pipeline flush),从⽽提⾼CPU的执⾏效率
- 只能保证⼀个共享变量的原⼦操作。当对⼀个共享变量执⾏操作时,我们可以使⽤循环CAS的⽅式来保 证原⼦操作,但是对多个共享变量操作时,循环CAS就⽆法保证操作的原⼦性,这个时候就可以⽤锁,或者有⼀个取巧的办法,就是把多个共享变量合并成⼀个共享变量来操作。⽐如有两个共享变量i=2,j=a,合 并⼀下ij=2a,然后⽤CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引⽤对象之间 的原⼦性,你可以把多个变量放在⼀个对象⾥来进⾏CAS操作。