0、线程不安全一般是指多线程情况下导致的数据不一致的问题,单线程下一般是安全的
1、CAS
循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁
synchronized属于悲观锁,它有一个明显的缺点,它不管数据存不存在竞争都加锁,随着并发量增加,且如果锁的时间比较长,其性能开销将会变得很大。有没有办法解决这个问题?答案是基于冲突检测的乐观锁。这种模式下,已经没有所谓的锁概念了,每个线程都直接先去执行操作,检测是否与其他线程存在共享数据竞争,如果没有则让此操作成功,如果存在共享数据竞争则不断地重新执行操作,直到成功为止,重新尝试的过程叫自旋。注意,长时间自旋会给CPU带来压力
2、我们经常使用volatile关键字修饰某一个变量,表明这个变量是全局共享的一个变量,同时具有了可见性和有序性。但是却没有原子性。比如说一个常见的操作a++。这个操作其实可以细分成三个步骤:
(1)从内存中读取a
(2)对a进行加1操作
(3)将a的值重新写入内存中
在单线程状态下这个操作没有一点问题,但是在多线程中就会出现各种各样的问题了。因为可能一个线程对a进行了加1操作,还没来得及写入内存,其他的线程就读取了旧值。造成了线程的不安全现象。如何去解决这个问题呢?最常见的方式就是使用AtomicInteger来修饰a。而AtomicInteger底层就是由CAS实现的
3、synchronized是悲观锁,这种线程一旦得到锁,其他需要锁的线程就挂起的情况就是悲观锁。
CAS操作的就是乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止
4、synchronized与Lock的区别
5、java.util.concurrent.locks包下常用的类
6、锁的相关概念
7、线程池
频繁创建(new)线程和销毁线程需要消耗大量时间资源(其实java中不仅是线程,创建和销毁任何对象都是要消耗不少资源的)
使用线程池使得线程可以复用:每个任务过来,就去线程池里面拿线程,处理完后,把线程放回线程池,避免频繁创建线程
在ThreadPoolExecutor类中有几个非常重要的方法:
execute()
submit() //实际上也是调用execute()方法
shutdown() //等待所有任务执行完毕再关掉线程池
shutdownNow() //立即关闭线程池
ThreadPoolExecutor构造函数中的重要参数
public ThreadPoolExecutor(int corePoolSize, intmaximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue workQueue,hreadFactory threadFactory,RejectedExecutionHandler handler)
1)corePoolSize(核心池大小)和maximumPoolSize
如果当前线程池中的线程数目小于corePoolSize,则每来一个任务,就会创建一个线程去执行这个任务;
如果当前线程池中的线程数目>=corePoolSize,则每来一个任务,会尝试将其添加到任务缓存队列当中,若添加成功,则该任务会等待空闲线程将其取出去执行;若添加失败(一般来说是任务缓存队列已满),则会尝试创建新的线程去执行这个任务;
如果当前线程池中的线程数目达到maximumPoolSize,则会采取任务拒绝策略进行处理;
corePoolSize就是常规线程池大小,maximumPoolSize可以看成是线程池的一种补救措施,即任务量突然过大时的一种补救措施,最大的线程池只能到这里。
2)workQueue该线程池中的任务队列:维护着等待执行的 Runnable 对象。当所有的核心线程都在干活时,新添加的任务会被添加到这个队列中等待处理,如果队列满了,则新建非核心线程执行任务
我们可以使用Executors类中提供的几个静态方法来创建线程池,它们实际上也是调用了ThreadPoolExecutor,只不过参数都已配置好了:
Executors.newCachedThreadPool(); //创建一个缓冲线程池,缓冲池容量大小为Integer.MAX_VALUE
Executors.newSingleThreadExecutor(); //创建容量为1的缓冲池
Executors.newFixedThreadPool(int); //创建固定容量大小的缓冲池
即上面的线程池的创建其实是调用类似下面的方法,只不过Java不推荐我们这样用
ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10, 200,TimeUnit.MILLISECONDS,new ArrayBlockingQueue(5));
线程池工作原理
当提交一个新任务到线程池中时,线程池的处理流程如下:
1、线程池判断核心线程池里的线程是否都在执行任务。如果不是,则创建一个新的工作线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。
2、线程池判断工作队列是否已经满。如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。
3、线程池判断最大线程池的线程是否都处于工作状态。如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务
8、Java中线程间通信:
9、悲观锁适合写多读少,要确保数据安全的场景。乐观锁适合读多写少,提高系统吞吐的场景
10、垃圾回收器
查看默认的垃圾回收器:java -XX:+PrintCommandLineFlags -version
jdk8环境下,默认使用 Parallel Scavenge(新生代)+ Serial Old(老年代)
jdk9环境下,默认使用G1回收器
垃圾回收可以有效的防止内存泄露,有效的使用空闲的内存,内存泄露是指该内存空间使用完毕之后未回收
垃圾回收操作需要消耗CPU、线程、时间等资源,所以容易理解的是垃圾回收操作不是实时的发生(对象死亡马上释放),当内存消耗完或者是达到某一个指标(Threshold,使用内存占总内存的比列,比如0.75)时,触发垃圾回收操作
几个相关概念:
并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。
吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
垃圾回收器有以下7种:
新生代垃圾回收器采用复制算法,年老代垃圾回收器采用标记整理或者标记清除算法
1)Serial收集器(新生代收集器)(复制算法)
Serial收集器是一个基本的,古老的新生代垃圾收集器。这个垃圾收集器是一个单线程的收集器,在它进行垃圾回收的时候,其他的工作线程都会被暂停,直到它收集结束。尽管Serial收集器有如此多的缺点,但是从JDK1.3开始到JDK1.7都一直是默认的运行在Client模式下的新生代收集器。原因在于Serial收集器是一个简单高效的收集器,没有线程切换的开销等等,在一般的Client应用中需要回收的内存也不是很大,垃圾回收停顿的时间不是很长,是可以接受的
2)ParNew收集器(新生代收集器)(复制算法)
ParNew收集器是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为跟Serial收集器一样,进行垃圾回收的时候,其他工作的线程都会被暂停。虽然ParNew收集器是多线程收集,但是它的性能并不一定比Serial收集器好。因为线程切换等开销的因素,在单CPU环境中它的性能是不如Serial收集器的,就算有2个CPU也不一定能说绝对比Serial好。但是随着CPU核数的增多,其最终效果肯定是优于Serial收集器的
3)Parallel Scavenge收集器(新生代收集器)(复制算法)
ParNew主要关注的是回收内存的速度,尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。
4)Serial Old收集器(老年代)(标记-整理算法)
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器
5)Parallel Old收集器(老年代)(标记-整理算法)
Parallel Old收集器是Parallel Scavenge收集器的老年代版本。直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器组合。
6)CMS收集器(标记-清除算法)
并发标记清除垃圾回收器,垃圾回收线程和用户线程可以并发执行,也可以产生短时间的stw,但是会产生碎片
7)G1收集器
和CMS一样,可以并发收集,不会产生碎片。
采用分治思想,将内存划分成许多不连续的region,每个region可以是年轻代也可以是年老代
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:CMS、Serial Old、Parallel Old
整堆收集器: G1
小数据量和小型应用,使用串行垃圾回收器即可。
对于对响应时间无特殊要求的,可以使用并行垃圾回收器和并发标记垃圾回收器。(中大型应用)
对于heap可以分配很大的中大型应用,使用G1垃圾回收器比较好,进一步优化和减少了GC暂停时间。
垃圾回收算法:
算法是方法论,回收器是算法落地实现
1)引用计数法
假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用失败时,对象A的引用计数器就-1,如果对象A的计算器的值为0,就说明对象A没有引用了,可以被回收
无法解决循环引用问题。(最大的缺点)
2)标记清除算法
先把所有活动的对象标记出来,然后把没有被标记的对象统一清除掉
3)标记整理算法
标记压缩算法是在标记清除算法的基础之上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的清理未标记的对象,而是将存活的对象压缩到内存的一端,然后清理边界以外的垃圾,从而解决了碎片化的问题。
4) 复制算法
复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收。
上述算法中,判断对象是否存活方法是gcroot可达性分析:可直接或间接引用到gcroot对象的对象,那么还是在使用的,不可回收
10、java中有两种方法:JAVA方法和本地方法。JAVA方法由JAVA编写,编译成字节码,存储在class文件中;本地方法由其它语言编写的,编译成和处理器相关的机器代码
11、java内存区域:
(1)程序计数器
一个比较小的内存区域,用于指示当前线程的字节码执行到了第几行,可以理解为是当前线程的行号指示器。线程私有,JVM内存中唯一没有定义OOM的区域
(2)虚拟机栈
运行java方法。线程私有,每创建一个线程,虚拟机就会为这个线程创建一个虚拟机栈,虚拟机栈表示Java方法执行的内存模型,每调用这个线程的一个方法就会为每个方法生成一个栈帧(Stack Frame),用来存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法被调用和完成的过程,都对应一个栈帧从虚拟机栈上入栈和出栈的过程。虚拟机栈的生命周期和线程是相同的。局部变量表中存储着方法的相关局部变量,包括各种基本数据类型,对象的引用,返回地址等
(3)本地方法栈
运行本地方法,其他跟虚拟机栈差不多。线程私有
(4)堆区:
JVM内存中最大的一块。也是JVM GC管理的主要区域。存储对象实例。所有线程共享。如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展。则OOM
(5)方法区
线程共享,存储已经被虚拟机加载的类信息、常量、静态变量(static)、即时编译器编译后的代码等。一般的,方法区上执行的垃圾收集是很少的,这也是方法区被称为永久代的原因之一(对于HotSpot虚拟机来说),但这也不代表着在方法区上完全没有垃圾收集,其上的垃圾收集主要是针对常量池的内存回收和对已加载类的卸载。
字符串常量池中的字符串只存在一份。
String s1 = “hello,world!”;
String s2 = “hello,world!”; 即执行完第一行代码后,常量池中已存在
“hello,world!”,那么s2不会在常量池中申请新的空间,而是直接把已存在的字符串内存地址返回给s2。 所以s1=s2
直接内存:除了JVM内存外的内存,比如机器一共有8G内存,JVM占了2G,直接内存就是6G
12、一般来说,一个Java的引用访问涉及到3个内存区域:JVM栈,堆,方法区。以最简单的本地变量引用:Object obj = new Object()为例:
Object obj表示一个本地引用,存储在JVM栈的本地变量表中,表示一个引用类型数据; new Object()作为实例对象数据存储在堆中;堆中还记录了这个类–Object类本身的的类型信息(接口、方法、field、对象类型等)的地址,这些地址所执行的数据存储在方法区中;
13、GC
根据对象的存活时间,把对象划分到不同的内存区域:年轻代,年老代,永久代
(1)年轻代:对象被创建时,内存的分配首先发生在年轻代(大对象可以直接被创建在年老代),大部分的对象在创建后很快就不再使用,因此很快变得不可达,于是被年轻代的GC机制清理掉,这个GC机制被称为Minor GC或叫Young GC。注意,MinorGC并不代表年轻代内存不足,一般来说只是年轻代的某个区域满了:eden区或者存活区,整个年轻代内存还是够的。
年轻代可以分为3个区域:Eden区和两个存活区Survivor 0 、Survivor 1
绝大多数刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快;当Eden区满的时候,执行Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区Survivor0(此时,Survivor1是空白的,两个Survivor总有一个是空白的);此后,每次Eden区满了,就执行一次Minor GC,并将剩余的对象都添加到Survivor0;当Survivor0也满的时候,将其中仍然活着的对象直接复制到Survivor1,以后Eden区执行Minor GC后,就将剩余的对象添加Survivor1(此时,Survivor0是空白的)。当两个存活区切换了几次(HotSpot虚拟机默认15次,用-XX:MaxTenuringThreshold控制,大于该值进入老年代)之后,仍然存活的对象(其实只有一小部分,比如,我们自己定义的对象),将被复制到老年代。由于绝大部分的对象都是短命的,甚至存活不到Survivor中,所以,Eden区与Survivor的比例较大,HotSpot默认是 8:1,即分别占新生代的80%,10%,10%
(2)年老代:对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次 YoungGC后存活了下来),则会被复制到年老代,年老代的空间一般比年轻代大,能存放更多的对象,在年老代上发生的GC次数也比年轻代少。当年老代内存不足时,将执行Major GC,也叫Full GC
(3)永久代:也就是方法区,其上的垃圾收集主要是针对常量池的内存回收(没有引用了就回收)和对已加载类的卸载
14、Java内存模型(与Java内存区域属于不同层次的概念)
java虚拟机有自己的内存模型(Java Memory Model,JMM),JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。由此就会导致某个线程对共享变量的修改对其他线程不可见
16、二叉树遍历方式 1).先序:根左右 2)中序:左根右 3)右序:左右根
17、Vector、HashTable、Properties是线程安全的。
ArrayList、LinkedList、HashSet、TreeSet、HashMap、TreeMap等都是线程不安全的。
stringbuffer是线程安全的,stringbuilder是非线程安全的, String是不可变类,所以是线程安全的。所有不可变类都是线程安全的
18、类加载过程
装载:将编译后的二进制文件(.class文件)加载进JVM;
链接:
验证:确保被加载类的字节流信息符合虚拟机要求,不会危害到虚拟机安全
准备:为类的静态变量分配内存,并将其初始化为默认值(比如静态int变量a,默认初始值为0);
解析:把类中的符号引用转换为直接引用
初始化:为类的静态变量赋予正确的初始值(a的正确初始值为5);
19、静态变量是在类加载的时候就初始化,所以它属于类,不属于某个对象。非静态变量就是在执行代码的时候初始化
20、方法重载是一个类中定义了多个方法名相同,而他们的参数的数量不同或数量相同而类型和次序不同。
方法重写是在子类存在方法与父类的方法的名字相同,而且参数的个数与类型一样,返回值也一样的方法,就称为重写(Overriding)。
方法重载是一个类的多态性表现,而方法重写是子类与父类的一种多态性表现
22、ArrayList和LinkedList,Vector,CopyOnWriteArrayList 的大致区别
24、栈区存引用和基本数据类型,不能存对象,而堆区存对象。
“ == ”是比较地址,equals()比较对象内容。
1)String str1 = “abcd"的实现过程
首先栈区创建str引用,然后在String池(独立于栈和堆而存在,存储不可变量)中寻找其指向的内容为"abcd"的对象,
如果String池中没有,则创建一个,然后str指向String池中的对象,如果有,则直接将str1指向"abcd”";如果后来又定义了字符串变量 str2 = “abcd”,则直接将str2引用指向String池中已经存在的“abcd”,不再重新创建对象; 此时进行str1 == str3操作,返回值为true
2)String str3 = new String(“abcd”)的实现过程
直接在堆中创建对象。如果后来又有String str4 = new String(“abcd”),str4不会指向之前的对象,而是重新创建一个对象并指向它。所以进行str3==str4返回值是false,因为两个对象的地址不一样,如果是str3.equals(str4),返回true,因为内容相同。
25、为什么hashmap是线程不安全的
如果多个线程同时使用put方法添加元素,而且假设正好存在两个put的key的hash值一样,这样可能会发生多个线程同时对Node数组进行扩容,扩容的时候就容易造成死循环
26、如何线程安全的使用HashMap
了解了HashMap为什么线程不安全,那现在看看如何线程安全的使用HashMap。这个无非就是以下三种方式:
Hashtable
ConcurrentHashMap
Synchronized Map
例子:
//Hashtable
Map hashtable = new Hashtable<>();
//synchronizedMap
Map synchronizedHashMap = Collections.synchronizedMap(new HashMap());
//ConcurrentHashMap
Map concurrentHashMap = new ConcurrentHashMap<>();
27、hashmap, hashtable, concurrenthashmap
线程不安全的HashMap
多线程环境下,使用Hashmap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。
效率低下的HashTable容器
HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下
ConcurrentHashMap分段锁技术
ConcurrentHashMap是Java5中支持高并发、高吞吐量的线程安全HashMap实现。ConcurrentHashMap中的分段锁称为Segment,类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。而且,其可以做到读取数据不加锁。线程占用其中一个Segment时,其他线程可正常访问其他段数据。Segment是一种可重入锁ReentrantLock。
ConcurrentHashMap的并发度是什么
CCHM的并发度就是segment的大小,默认为16,这意味着最多同时可以有16条线程操作CCHM,这也是CCHM对Hashtable的最大优势。
concurrenthashmap使用注意事项: ConcurrentHashmap、Hashtable不支持key或者value为null,所以需要处理value不存在和存在两种情况。而HashMap是支持的
28、单例模式
单例的意思是这个类只有一个实例。单例有好几种写法,这里只列出两种:
1)饿汉单例模式
public class EHanSingleton {
//static final单例对象,类加载的时候就初始化
private static final EHanSingleton instance = new EHanSingleton();
//私有构造方法,使得外界不能直接new
private EHanSingleton() {
}
//公有静态方法,对外提供获取单例接口
public static EHanSingleton getInstance() {
return instance;
}
}
缺点:如果有大量的类都采用了饿汉单例模式,那么在类加载的阶段,会初始化很多暂时还没有用到的对象,这样肯定会浪费内存,影响性能
2)静态内部类实现单例模式(推荐使用这种模式)
public class StaticClassSingleton {
//私有的构造方法,防止new
private StaticClassSingleton() {
}
public static StaticClassSingleton getInstance() {
return StaticClassSingletonHolder.instance;
}
//静态内部类
private static class StaticClassSingletonHolder {
//第一次加载内部类的时候,实例化单例对象
private static final StaticClassSingleton instance = new StaticClassSingleton();
}
}
第一次加载StaticClassSingleton类时,并不会实例化instance。只有第一次调用getInstance方法时,
Java虚拟机才会去加载内部类:StaticClassSingletonHolder类,继而实例化instance,这样延时实例化instance,节省了内存,并且也是线程安全的
29、实现并启动线程有两种方法
1)写一个类继承自Thread类,重写run方法。用start方法启动线程;
2)写一个类实现Runnable接口,实现run方法。用new Thread(Runnable target).start()方法来启动。
在start方法里面其实就是调用的run方法。那为什么不直接调用run方法?
因为如果直接调用run方法,并不会创建另一个线程,程序中只有主线程一个线程,所以并不是多线程。而start方法就是单独开一个线程去跑run里面的代码,与此同时,主线程也会同时进行。实现真正的多线程
30、synchronized和ReentrantLock的区别
synchronized是和if、else、for、while一样的关键字,ReentrantLock是类,这是二者的本质区别。既然ReentrantLock是类,那么它就提供了比synchronized更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量,ReentrantLock比synchronized的扩展性体现在几点上:
(1)ReentrantLock可以对获取锁的等待时间进行设置,这样就避免了死锁
(2)ReentrantLock可以获取各种锁的信息
(3)ReentrantLock可以灵活地实现多路通知
31、冒泡排序
arr = [1, 7, 3, 6, 10, 2]
length = len(arr)
for i in range(length):
for j in range(length - i - 1):
if arr[j] > arr[j+1]: # 将最大值放到最右边,那么上一行代码的循环范围就必须是(0, length - i - 1)
tmp = arr[j+1]
arr[j+1] = arr[j]
arr[j] = tmp
print(arr)
二分查找
arr2 = [1, 3, 4, 6, 9, 10]
start = 0
tail = len(arr2) - 1
while start <= tail: # 一定要加 = 的情况,因为当start和tail相等的情况下,这个start值(也就是middle值,也就是tail值)其实就是所求值
middle = (start+tail)//2 # //为取整
if arr2[middle] == 6:
print(middle)
break
elif arr2[middle] < 6:
start = middle + 1
elif arr2[middle] > 6:
tail = middle - 1
32、static的使用:
(1)static修饰的变量,方法或者类,在类加载到jvm的时候,就已经实例化了。只实例化一次,在内存中只有一份。一般工具类或者常量这么做
(2)只初始化一次。然后再用时都是直接从内存中得到。不需要再初始化
(3)如果这个方法是作为一个工具来使用,就声明为static,不用new一个对象出来就可以使用了,直接用类进行调用
(4)不用new就能直接调用,这样也能省去new对象的内存使用,提高效率
33、HashMap原理
本质是一个一定长度的数组,数组中存放的是链表。
数组
数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;
链表 链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。
hashmap是一个默认长度为16的entry数组,即Entry[],即里面每一个元素都是都是Entry类型,这个类重要的属性有 key, value, next,hash(key的hash值)。因为这个next链表属性,所以可以把Entry类型看作链表类型
hashmap元素(k-v)存放位置index的规则:
传统方式:index = hash(key) % 16
hashmap:index = hash(key) & (16-1)
简单理解可以通过比较传统的hash(key)%len获得,也就是元素的key的哈希值对数组长度取模得到。比如12%16=12,28%16=12,所以12、28都存储在数组下标为12的位置
但是其实hashmap内部并不是用取模运算,“模”运算的消耗还是比较大的。hashmap用了很多位运算(左移右移,异或)来获取index,位运算的方式比取模效率高一个量级
Hashmap为什么大小是2的幂次?
因为在计算元素该存放的位置的时候,用到的算法是将元素的hashcode与当前map长度-1进行与运算。源码:
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : “length must be a non-zero power of 2”;
return h & (length-1);
}
如果map长度为2的幂次,那长度-1的二进制一定为11111…这种形式,进行与运算就看元素的hashcode,但是如果map的长度不是2的幂次,比如为15,那长度-1就是14,二进制为1110,无论与谁相与最后一位一定是0,0001,0011,0101,1001,1011,0111,1101这几个位置就永远都不能存放元素了,空间浪费相当大。也增加了添加元素是发生碰撞的机会。减慢了查询效率。所以Hashmap的大小是2的幂次。
hashmap存取实现
Entry类里面有一个next属性,作用是指向下一个Entry。打个比方,
第一个键值对A进来,通过计算其key的hash得到的index=5,然后会生成一个entryA对象,此时Entry[5] = entryA。一会后又进来一个键值对B,通过计算其index也等于5,现在怎么办?HashMap会这样做:
entryB.next = entryA //entryB里面包含next属性,所以先要对entryB的next赋值
Entry[5] = entryB
如果又进来entryC,index也等于5,那么
entryC.next = entryB,Entry[5] =entryC;
这样我们发现index=5的地方其实存取了A,B,C三个键值对的Entry对象,他们通过next这个属性链接在一起。也就是说数组中存储的是最后插入的元素。而Entry[5]其实就是该链表的头部,把最后插入的元素插在头部,所以插入效率很高,这就是头插法
//向hashmap中put一个k-v元素源码
public V put(K key, V value) {
if (key == null)
return putForNullKey(value); //null总是放在数组的第一个链表中
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length); //得到该k-v元素在数组中的位置i
//遍历位置i上面的链表
for (Entry e = table[i]; e != null; e = e.next) {
Object k;
//如果key在链表中已存在,则替换为新value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
//如果key不存在,则在链表上新增一个Entry对象
addEntry(hash, key, value, i);
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry e = table[bucketIndex]; //取出位置i上的当前链表
table[bucketIndex] = new Entry(hash, key, value, e); //重新给位置数组位置i赋值,这个值即为新的Entry,包含k的hash值,当前要插入的k,v,以及当前位置的上一个Entry,e是一个引用,指向上一个Entry
//如果size超过threshold,则扩充table大小。再散列
if (size++ >= threshold)
resize(2 * table.length);
}
# get源码
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
//先定位到数组元素,再遍历该元素处的链表
for (Entry e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
hashmap的resize
当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),即链表长度越来越大。所以为了提高查询的效率,就要对hashmap的数组进行扩容
而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。
那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小loadFactor时,并且当前put操作的index上已经有元素了,就会进行数组扩容,如果put操作是插到一个没有元素的index上,即使超过了负载因子也不会扩容。loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过160.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适
hashmap线程不安全
hashmap扩容,会使链表反序,这个在单线程下是没有问题的。然而多线程下当多个线程同时扩容的时候,反序链表会导致环形链表的出现.一旦出现了环那么在while(null !=p.next){}的循环的时候.就会出现死循环导致线程阻塞.
HashMap扩容导致死循环的主要原因在于扩容后链表中的节点在新的hash桶使用头插法插入。新的hash桶会倒置原hash桶中的单链表,那么在多个线程同时扩容的情况下就可能导致产生一个存在闭环的单链表,从而导致死循环。
JDK8的修复
首先通过上面的分析我们知道JDK 1.7中HashMap扩容发生死循环的主要原因在于扩容后链表倒置以及链表过长。
那么在JDK 1.8中HashMap扩容不会造成死循环的主要原因就从这两个角度去分析一下。
由于扩容是按两倍进行扩,即 N 扩为 N + N,因此就会存在低位部分 0 - (N-1),以及高位部分 N - (2N-1),所以在扩容时分为 loHead (low Head) 和 hiHead (high head)。这样能减少一半链表长度。
然后将原hash桶中单链表上的节点按照尾插法插入到loHead和hiHead所引用的单链表中。由于使用的是尾插法,不会导致单链表的倒置,所以扩容的时候不会导致死循环。
通过上面的分析,不难发现循环的产生是因为新链表的顺序跟旧的链表是完全相反的,所以只要保证建新链时还是按照原来的顺序的话就不会产生循环。
如果单链表的长度达到 8 ,就会自动转成红黑树,而转成红黑树之前产生的单链表的逻辑也是借助loHead (low Head) 和hiHead (high head),采用尾插法。然后再根据单链表生成红黑树,也不会导致发生死循环。
这里虽然JDK 1.8中HashMap扩容的时候不会造成死循环,但是如果多个线程同时执行put操作,可能会导致同时向一个单链表中插入数据,从而导致数据丢失的。
所以不论是JDK 1.7 还是 1.8,HashMap线程都是不安全的,要使用线程安全的Map可以考虑ConcurrentHashMap。
hashmap jdk1.7和1.8的区别
在jdk1.7中,HashMap中有个内置Entry类,它实现了Map.Entry接口;而在jdk1.8中,这个Entry类不见了,变成了Node类,也实现了Map.Entry接口,与jdk1.7中的Entry是等价的。也就是node数组和Entry数组是等价的
链表如何转红黑树?其实就是循环链表的每一个元素,然后根据该元素new一个树节点,最后由这些树节点组成一棵红黑树
34、hashmap和hashtable的区别
两者都是基于哈希表实现。底层都是通过数组加链表的结构实现
35、ConcurrentHashMap
ConcurrentHashMap是Java中的一个线程安全且高效的HashMap实现。平时涉及高并发如果要用map结构,那第一时间想到的就是它。
//concurrnthashmap的node
static class Node implements Map.Entry {
final int hash;
final K key;
volatile V val;
volatile Node next;
}
//hashmap的node
static class Node implements Map.Entry {
final int hash; // hash值,不可变
final K key; // 键,不可变
V value; // 值
Node next; // 下一个节点
static final class HashEntry {
final int hash;
final K key;
volatile V value;
volatile HashEntry next;
}
36、volatile
内存模型在多核CPU多线程编程环境下,对共享变量读写的原子性、可见性和有序性。只是一种规范,是抽象的,不真实存在的,为了实现这种规范,需要借助一些关键字或者类实现,比如volatile
37、queue也是一种collection
38、同步队列是阻塞队列的一种,只有一个元素,生产一个元素必须消费一个元素
39、hashset里面存储的元素都具有无序性,标识唯一性。hashset里面大多数的内容都是在hashmap的基础上进行修改的。实际上是HashMap中value=null的实现
40、公平锁:线程获得锁的顺序跟申请锁的顺序一致。
可重入锁:又称递归锁,假如在同步方法A中又调用了同步方法B,那么如果一个线程获得了A的锁,它也会获得B的锁
可重入读写锁:一般的重入锁不管是读操作还是写操作,都不是共享的,即只有一个线程能进行读操作,或者写操作。这样性能很差,比如缓存系统,对于读操作,锁应该是共享的。对于写操作,锁应该是独占的,这样保证写的原子性。而可重入读写锁能实现读时锁共享,写时锁独占,提升整体性能
41、多线程判断要用while而不是if
42、wait-notify: 线程间通信
wait:使当前线程挂起,并马上释放掉对象的锁
notify:通知当前线程锁住的对象上挂起的线程,唤醒任一线程,但是此时并不是马上释放锁,只有当同步代码块执行完了,才会释放锁
public class Demo {
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
//两个线程都对同一对象加锁
synchronized (Demo.class) {
try {
System.out.println(new Date() + " Thread1 is running");
Demo.class.wait(); //运行到wait,线程1立马挂起,并释放锁
System.out.println(new Date() + " Thread1 ended");
} catch (Exception ex) {
ex.printStackTrace();
}
}
});
thread1.start();
Thread thread2 = new Thread(() -> {
//两个线程都对同一对象加锁
synchronized (Demo.class) {
try {
System.out.println(new Date() + " Thread2 is running");
Demo.class.notify(); //运行到notify,同一对象-Demo.class上阻塞的线程会被唤醒,但是此时锁还没释放
System.out.println(new Date() + " Thread2 notify");
Thread.sleep(2000);
System.out.println(new Date() + " Thread2 release lock");
} catch (Exception ex) {
ex.printStackTrace();
}
}
//程序除了synchronized代码块,线程2释放锁。线程1立马继续执行。打印Thread1 ended。线程2休眠2S后,打印Thread2 ended
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(new Date() + " Thread2 ended");
});
thread2.start();
}
}
43、生产者消费者模式
生产者和消费者操作同一对象,当对象为空时,消费者线程阻塞。当对象满了,生产者线程阻塞
//使用wait-notify实现生产者消费者模式
public class ProCon {
private static String lock = "lock";
public static void main(String[] args) throws InterruptedException {
final List list = new ArrayList<>(10); //生产者和消费者操作的同一对象
produce produce1 = new produce(list);
consumer consumer = new consumer(list);
//消费者线程先开启,此时list为空,消费者线程阻塞
consumer.start();
Thread.sleep(2000);
produce1.start();
}
static class produce extends Thread {
final List list;
public produce(List list) {
this.list = list;
}
@Override
public void run() {
while (true) {
//生产者消费者必须都进行同步,且用同一把锁,即锁住同一对象,否则生产者还没生产好对象,消费者就去消费了,消费的对象就不完整
synchronized (lock) {
while (list.size() == 3) {
try {
System.out.println("生产数据已满 线程" + Thread.currentThread().getName() + "已停止");
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
list.add(1);
lock.notifyAll(); //生产者已经生产数据,唤醒阻塞的消费者线程
System.out.println("线程" + Thread.currentThread().getName() + "正在生产数据" + "size " + list.size());
}
}
}
}
static class consumer extends Thread {
final List list;
public consumer(List list) {
this.list = list;
}
@Override
public void run() {
while (true) {
synchronized (lock) {
while (list.isEmpty()) {
try {
System.out.println("消费数据已空 线程" + Thread.currentThread().getName() + "已停止");
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int random = list.remove(0);
lock.notifyAll();
System.out.println("线程" + Thread.currentThread().getName() + "正在消费数据");
}
}
}
}
}
//使用阻塞队列实现生产者消费者模式:上面的加锁,队列为空消费阻塞、队列已满生产阻塞等等机制阻塞队列内部已经帮你实现,不用管
public class ProCon2 {
public static void main(String[] args) {
final ArrayBlockingQueue queue=new ArrayBlockingQueue<>(10);
produce produce=new produce(queue);
consumer consumer=new consumer(queue);
consumer.start();
produce.start();
}
static class produce extends Thread{
final ArrayBlockingQueue integerArrayBlockingQueue;
public produce(ArrayBlockingQueue integerArrayBlockingQueue) {
this.integerArrayBlockingQueue = integerArrayBlockingQueue;
}
@Override
public void run() {
while (true){
Integer random=new Random().nextInt(10);
integerArrayBlockingQueue.add(random);
System.out.println("生产数据" + random);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
static class consumer extends Thread{
final ArrayBlockingQueue integerArrayBlockingQueue;
public consumer(ArrayBlockingQueue integerArrayBlockingQueue) {
this.integerArrayBlockingQueue = integerArrayBlockingQueue;
}
@Override
public void run() {
while (true){
try {
Integer element=integerArrayBlockingQueue.take();
System.out.println("消费数据 "+element);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
44、synchronize,重入锁等只在这些锁只在单个jvm(单个tomcat,或者说单个机器节点)下有效,在分布式条件下不能保证数据一致性
45、oom
java.lang.OutOfMemoryError:Java heap spacess :强调内存不够,装不下对象,此时GC可能还是正常的
java.lang.OutOfMemoryError: GC overhead limit exceeded Gc回收时间过长会发生outofmemoryerror,过长的定义是,如果超过98%的时间来做GC,并且回收了不到2%的堆内存。强调一直GC却不起作用
java.lang.OutOfMemoryError: Unable to create new native thread意味着Java应用程序已达到其可以启动线程数的限制。
StackOverflowError
原因 : 函数调用栈太深了,注意代码中是否有了循环调用方法而无法退出的情况
原理
StackOverflowError 是一个java中常出现的错误:在jvm运行时的数据区域中有一个java虚拟机栈,当执行java方法时会进行压栈弹栈的操作。在栈中会保存局部变量,操作数栈,方法出口等等。jvm规定了栈的最大深度,当执行时栈的深度大于了规定的深度,就会抛出StackOverflowError错误。