因为作为一名Java开发工程师,在些代码的时候必然会用到一些数据结构,其中尤为经典的就是HashMap,关于HashMap考察会很多
map.put(“张三”,“测试数据”)
首先会对"张三"这个key计算他的hash值,是有一定的优化的,请看源码(jdk1.8)
static final int hash(Object key){
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h>>>16);
}
经过一系列二进制的与或,取模,进行数组寻址(取模运算,性能是比较差一些的,要优化这个数组的寻址过程)
hash & (n-1) -> 效果是跟hash对n取模是一样的,但是与运算的性能要比hash对n取模要高很多,注:这是一个数学问题:数组的长度一直是2的n次方,只要他保持数组长度是2的n次方,hahs对n取模的效果 ->hash & (n-1),效果是一样的,但是后者性能更高
比如说有一个hash值,将高16位和低16位进行与运算,这样运算出来的结果在低16位会融合了之前高16位和低16位的值,这样可以避免在hash值一样->导致他们都会放在数组的一个位置,进行复杂的hash冲突的处理
总结:
hash算法的优化:对每个hash值,在它的低16位中,让高低16位进行异或,让它的低16位同时保持了高低16位的特征,尽量避免了一些hash值后续出现冲突,导致会进入数组的同一个位置
寻址算法的优化:用与运算,替代取模,提升性能
hash冲突问题,链表+红黑树,O(n)和O(log n)
可能出现的问题:
多个key,它们算出来的hash的值,与n-1,与运算之后,发现定位出来的数组的位置还是一样的
解决方案:
会在这个位置挂一个链表,这个链表里面放入多个元素,让多个key-value对,同时放在数据的一个位置里
执行get方法的时候,如果定位到这个数组里这个位置挂了一个链表,此时就会遍历链表,从中找到那个key-value就行了
注:可能出现的问题,加入链表很长,可能导致遍历链表的性能会比较差,O(n)
针对这个问题做了优化:如果链表的长度达到了一定的长度之后,其实会把这个链表转换为红黑树,遍历一颗红黑树,时间复杂度
是O(log n),性能比链表高一些
底层是一个数组,当这个数组满了之后,他就会自动进行扩容,变成一个更大的数组,让你在里面可以去放更多的元素
–>2倍扩容
数组长度=16,扩容之后要进行rehash策略
rehash策略:
判断二进制结果中是否多一个bit的1,如果没多,那么就是原来的index,如果多出来,那么就是index+oldCap(原来数组长度),通过这个方式,就避免了rehash的时候,用每个hash对新数组.length取模,取模性能不高,位运算的性能比较高
了解:加锁一般来说是对一个对象进行加锁的,进行加锁的时候,底层会执行moniterentry,释放锁的时候会执行monitorexit
执行monitorentry指令的时候会干什么呢?
每个对象都关联一个monitor,比如说一个对象实例就有一个monitor,一个类的Class对象也有一个monitor,如果要对这个对象加锁,那么必须获取这个对象关联的monitor的lock锁
原理:monitor里面有一个计数器,从0开始,如果一个线程要获取monitor锁,就看看他的计数器是不是0,如果是0的话,那么说明没人获取锁,他就可以获取锁了,然后对计数器加1,如果说线程走出了synchronized片段,就会有一个monitorexit指令,此时获取锁的线程就会对那个对象的monitor计数器减1,如果有多次重入加锁就会对应多次减1,直到最后,计数器是0
前提:多个线程可能要访问同一个数据
HashMap map = new HashMap();
此时有多个线程要同时读写类似上面的这种内存的数据,必然会出现多线程的并发安全问题
synchronized(map){
//对map里的数据进行复杂的读写处理
}
CAS(compare and set)
单纯的使用synchronized可能会导致效率低下,很多线程都需要去排队执行
public class MyObject{
AtomicInteger i = new AtomicInteger(0);//底层基于CAS来实现的
//多个线程此时来执行这个代码
//不需要synchronized加锁,也是线程安全的
public void increment(){
i.incrementAndGet();
}
}
CAS在底层的硬件级别给你保证一定是原子的,同一时间只有一个线程可以执行CAS,先比较再设置,其他线程的CAS同时间去执行,此时会失败
前提:多个线程要访问同一个数据:synchronized加锁,CAS去进行安全的累加。来实现多线程场景下的安全的更新一个数据的效果,比较多的场景下,可能就是多个线程同时读写一个HashMap
在JDK1.7以及以前的版本,是进行分段加锁的
[数组1],[数组2],[数组3] ->每个数组都对应一个锁
JDK1.8之后,做了一些优化和改进,进行了锁粒度的细化
数组里的每个元素进行put操作,都是有一个不同的锁,刚开始进行put的时候,如果两个线程都是在数组[5]这个位置进行put,这个时候,对数组[5]这个位置进行put的时候,是进行的CAS的策略
通过对数组每个元素执行CAS的策略,如果是很多线程对数组里不同的元素执行put,大家是没有关系的,如果说之前数组[5]这个位置已经有人放进去值了,就需要在这个位置基于链表+红黑数来进行处理,synchronized(数组[5])加锁,基于链表或者是红黑树在这个位置插进去自己的数据
前提:多个线程访问同一个共享数据,除了用synchronized,CAS,ConcurrentHashMap,Lock,还可以用AQS(abstract queue synchronizer 抽象队列同步器)
ReentrantLock lock = new ReentrantLock(true); //默认是非公平锁,传值为true就是公平锁
lock.lock();//进行加锁
//处理复杂的业务逻辑
lock.unlock();//释放锁
系统是不可能的无限制的创建很多线程的,会构建一个线程池,有一定数量的线程,让他们执行各种各样的任务,线程执行完任务之后,不要销毁掉自己,继续去等待执行下一个任务
ExecutorService threadPool = Executors.newFixedThreadPool(3); //3:corePoolSize:核心线程池的数量
threadPool.submit(new Callable(){
public void run(){}
});
线程池是空的,当前没有线程,并且会带一个新的队列,一开始有提交任务时候,会比较当前线程池数量是否小于corePoolSize,小于的话会创建一个线程,反复执行上述流程,只要线程池的数量小于corePoolSize,都会直接创建新线程来执行这个任务,执行晚了就尝试从无界队列里获取任务,知道线程池有corePoolSize个线程
设置线程池
接着再提交任务,会发现线程数量已经跟corePoolSize一样大了,此时就直接把任务放入队列中就可以了,线程会争抢获取任务执行的,如果所有的线程此时都在执行任务,那么无界队列的任务就会越来越多
newFixedThreadPool队列而言,是LinkedBlockingQueue(无界阻塞队列),就是说队列的长度是无限大的
new ThreadPoolExecutor(nThreads,nThreads,0L,TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());
//corePoolSize:3
//maximumPoolSize:Integer.MAX_VALUE
//keepAliveTime:60s
//new ArrayBlockingQueue<Runnable>(200)//队列最多放的任务数
如果说任务的增长速度大于线程池的处理速度,会额外的生成线程出来,最多能创建maximumPoolsize个额外线程
假设任务被处理的差不多了,这些额外的线程会空闲一段时间,当空闲时间等于keepAliveTime时,额外的线程就会被销毁掉
如果说额外的线程还不够,并且任务还在不断的增长,会有拒绝策略,可以传入RejectedExecutionHandler
拒绝策略有以下几种:
1.AbortPolicy 2.DiscardPolicy 3.DiscardOldestPolicy 4.CallerRunsPolicy 5.自定义
常用的线程池就是newFixedThreadPool
面试题:在远程服务异常的情况下,使用无界阻塞队列,是否会导致内存异常飙升?
答:调用超时,队列(栈内存)会变得越来越大,此时会导致内存飙升起来,而且还可能会导致OOM,内存溢出
产生问题:必然会导致线程池里积压的任务实际上来说会是丢失的
解决方案:如果说要提交一个任务大线程池里去,在提交之前,麻烦现在数据库里插入这任务的信息,更新他的状态:未提交,已提交,已完成。提交成功之后,更新他的状态是已提交状态,系统重启之后,后台线程去扫描数据库里的未提交和已提交状态的任务。可以把任务的信息读取出来,重新提交到线程池里去,继续进行执行
public class HelloWord(){
private int data = 0;
public void increment(){
data++;
}
}
HelloWord helloword = new HelloWord();//对象其实是在堆内存里,包含实例变量
//线程1
new Thread(){
public void run(){
helloword.increment();
}
}.start();
//线程2
new Thread(){
public void run(){
helloword.increment();
}
}.start();
1.可见性:
根据《谈一下对Java内存模型的理解》的流程图,可以看出,线程1在进行对data=0进行一系列read,load,use,assign,store操作,把data=1,因为加上了volatile关键字,在同时会失效掉线程2工作内存里面的缓存,这样在线程2进行use的时候,会发现工作内存里面的缓存已经失效了,会强制从主内存里面加载
2.禁止指令重排
java中有一个happens-before原则:
编译器、指令起可能对代码重排序,乱排,要守一定的规则,happens-before原则,只要符合happens-before的原则,那么就不能胡乱重排,如果不符合这些规则的话,那就可以自己排序
连环炮:内存模型 -> 原子性、可见性、有序性 -> volatile+可见性 ->volatile+有序性(指令重排+happens-before) ->volatile底层的原理(内存屏障级别的原理)
volatile+原子性:不能够保证原子性,虽然说有些极端特殊情况下有保证原子性的效果
使用synchronized、lock保证原子性
禁止指令重排:底层是前后进行读写的时候,加入了内存屏障
保证可见性:对volatile修饰的变量,执行写操作的话,JVM会发送一条lock前缀指令给CPU,CPU在计算完之后会立即将这个值写回主内存,同时因为有MESI缓存一致性协议,所以各个CPU都会对总线进行嗅探,自己本地缓存中的数据是否被别人修改,如果发现了别人修改了某个缓存的数据,那么CPU就会将自己本地缓存的数据过期掉,然后这个CPU上执行的线程在读取那个变量的时候,就会从主内存重新加载最新的数据了
Spring 核心框架里面,最关键的两个机制,就是ioc和aop。根据xml配置或者注解,去实例化我们所有的bean,管理bean之间的依赖注入,让类与类之间的耦合性降低,维护代码的时候更加方便轻松。
AOP是面向切面编程,简单的说就是把我们重复的代码抽取出来,在需要执行的时候,使用动态代理技术,在不修改源码的基础上,对我们已有的方法进行增强。
AOP的作用和优势
作用:在程序运行期间,不修改源码对已有方法进行增强。
优势:减少重复代码,提高开发效率,维护方便
jdk动态代理:如果类实现类某个借口,Spring aop就会使用jdk动态代理,生成一个实现同样接口的代理类,构造一个实例对象出来
cglib动态代理:如果很多时候某个类没有实现接口,Spring aop会该用cglib来生成动态代理,生成类的子类,覆盖方法,在方法里加入增强代码
Spring容器中的bean可以分为5个范围:
Spring中的bean是不是线程安全的,答案是否定的,spring bean默认来说是单例的,是线程不安全的。但是java web系统中,一般来说很少在spring bean中放一些实例变量,通常都是多个组件互相调用,最终去访问数据库的,所以一般结果就是多个线程并发的访问数据库。
事务的实现原理:事务传播机制,如果加了一个@Transactional注解,此时就spring会使用aop思想,对这个方法在执行之前,先去开启事务,执行完毕之后,根据你方法是否报错,来决定回滚还是提交事务
比如说,我们现在有一段业务逻辑,方法A调用方法B,我希望的是如果说方法A出错了,此时仅仅回滚方法A,不能回滚方法B,必须得用REQUIRES-NEW,传播机制,让他们两的事务是不同的。
方法A调用方法B,如果出错,方法B只能回滚他自己,方法A可以带着方法B一起回滚,NESTED嵌套事务。
spring的生命周期:
实例化Bean:
对于BeanFactory容器,当客户向容器请求一个尚未初始化的bean时,或初始化bean的时候需要注入另一个尚未初始化的依赖时,容器就会调用createBean进行实例化。对于AplicationContext容器,当容器启动结束后,通过获取BeanDefinition对象中的信息,实例化所有bean
设置对象属性(依赖注入)
实例化后的对象被封装在BeanWrapper对象中,紧接着,Spring根据BeanDefinition中的信息以及通过BeanWrapper提供的设置属性的接口完成依赖注入,比如说通过构造函数注入,setter
public class Service{
private MyDao Mydao;
public MyService(){
this.myDao=myDao;
}
}
public void setMyDao(MyDao myDao){
this.myDao=myDao;
}
public class MyControll{
private MyService myService = MyServiceFactory.getMyService();
}
public class MyServiceFactory{
public static MyService getMyService(){
return new MyServiceImpl();
}
}
public class MyService(){
private static volatile MyService myService;
public static MyService getInstance(){
if(myService == null){
synchronized(MyService.class){
if(myService == null){
myService = new MyService();
}
}
}
}
}
我们在执行代码的时候,肯定会有很多线程,tomcat里就有很多自己的工作线程,去执行我们写的代码,每个工作线程都会有自己的一块数据结构:栈内存,这个里面会存放一些东西
java 8以后的内存分代的改进,永久代里放了一些常量池+类信息,常量池 ->堆内存,类信息 -> metaspace(元空间)
JVM分配流程图:
假如给年轻代一共是2GB内存,给老年代是2GB内存,默认情况下,eden和2个S(S1和S2区域)的比例是8:1:1
如果说eden区域满了,此时必然会出现垃圾回收,ygc(young gc)
回收的对象:没有人引用的对象 注:有方法引用的对象、static静态变量不能被认为是垃圾对象
如果说让代码一边运行,一边有变动,一边判断哪些对象是可以回收的,这是不现实的,垃圾回收的时候有一个概念,叫做stop the world 停止jvm里的工作线程的运行,然后扫描所有对象,判断哪些可以回收,哪些不可以回收
年轻代:大部分情况下,对象生存周期是很短的,此时对象方法执行结束了,就可以认为是垃圾,可以回收
对于年轻代的垃圾回收算法,采取的是复制算法,一次young gc 就完成了一次垃圾回收
什么情况下,年轻代的对象会转移到老年代中?
老年代对象越来越多,空间也会满的,但是不合适使用年轻代的复制算法,因为老年代里的对象,很多都是被长期引用的,里面有spring管理的各种bean
对于老年代而言,他里面的垃圾可能是没有那么多的,采用标记-清理,找出来那些对象,然后直接把垃圾对象在老年代里清理掉,但是这样会产生一个内存碎片的问题。故采用标记-压缩把老年代里面存活的对象标记出来,移动到一起,存活的对象压缩到一片内存空间里去,这样剩余的空间都是垃圾对抗给整个清理掉,剩余的都是连续可用的内存空间,解决了内存碎片的问题
常用的垃圾回收器:
parnew+cms的组合
parnew是使用多个线程,并发的把垃圾对象清理掉
cms是老年代回收的方法,使用在jdk8及以前,分成好几个阶段:初始标记->并发标记->并发预清理->重新标记->并发清理->并发重置,老年代的垃圾回收是比较慢的,一般来说比年轻代慢10倍以上
g1直接分代回收,直接可以同时回收年轻代和老年代,使用在jdk9及以后
举例子:
A客户端:给服务端B说,我想要和你建立连接
B服务端:给A客户端说,我已经收到你的信息了,可以建立连接
A客户端:好的,我已经知道连接已经建立了
如果是两次握手会出现的问题:客户端发送的第一次握手卡在半路上了,所以又重新发送了一次连接,这时,卡在半路上的连接竟然通了,服务端会返回第二次握手,但是这次连接已经不需要了,如果不进行第三次握手,服务端会开辟资源准备通信,所以这时候需要客户端发送第三次握手,要求释放资源
https的工作原理:
B+树和B-树不太一样的地方在于:
先说一下事务的ACID
innodb的行锁有共享锁(s)和排他锁(x)两种,共享锁就是多个事务都可以加共享锁读同一行数据,但是别的事务不能写这行数据,排他锁,就是一个事务可以写这行数据,别的事务只能读,不能写
innodb的表锁,分为意向共享锁,就是说加共享行锁的时候,必须先加这个共享表锁,还有一个意向排他锁,就是说,给某行加排他锁的时候,必须先给表加排他锁,是innodb引擎自动加的,不用手动加
insert、update、delete,innodb会自动给那一行加行级排他锁
select,innodb啥锁都不加,因为innodb默认实现可重复读,也就是mvcc机制,所以多个事务随便读一个数据,一般不会有冲突,大家就读自己的那个快照就可以了,不涉及到什么锁的问题
explain查看索引的执行计划:
type:这个最重要了,all(全表扫描)、const(读常量,最多一条记录匹配)、eq、ref(走主键,一般就最多一条记录匹配)、
index(扫描全部索引)、range(扫描部分索引)
possible_keys:显示可能使用的索引
key:实际使用的索引
key_len:使用索引的长度
ref:联合索引的哪一列被哦那个了
rows:一共扫描和返回了多少行
extra:using(需要额外进行排序)、using temporary(mysql构建了临时表,常见于自查询,比如排序的时候)、
using where(就是对索引扫出来的数据再次根据where来过滤出了结果)
原子性:基本的赋值写操作都是可以保证原子性的,复杂的操作是无法保证原子性的
可见性:MESI协议、flush、refresh,配合起来,才可以解决可见性
有序性:三个层次,最后一个层次有4种重排(LoadLoad、StoreStore、LoadStore、StoreLoad)
synchronized是同时可以保证原子性、可见性以及有序性
原子性的层面而言,他加了之后,有一个加锁和释放锁的机制,加锁了之后,同一段代码就只有他可以执行了
可见性,在同步代码块对变量做的写操作,都会在释放锁的时候,全部强制执行flush操作,在进入同步代码块的时候,对变量的读操作,全部会强制执行refresh操作,更新的数据,别的线程只要进入代码块,就一定可以读到
有序性,synchronized关键字,会通过加各种内存屏障,来解决LoadLoad、StoreStore等重排序
在出锁代码块的时候,加一个monitorexit的指令,然后递减锁计数器,如果锁计数为0,就会标志当前线程不持有锁,释放锁
MyObject lock = new MyObject();
synchronized(){
}
int b = 0;
int c = 0;
synchronized(this){//->monitorenter
//load内存屏障
//Acquire内存屏障
int a = b;
c =1;//synchronized代码块里面还是可能发生指令重排
//Release内存屏障
}//->monitorexit
//store内存屏障
按照可见性来划分的话,内存屏障可以分为Load屏障和Store屏障
Load屏障的作用是执行refresh处理器缓存的操作,说白了就是对别的树立起更新过的变量,从其他处理器的高速缓存(或则是主内存)加载数据到自己的高速缓存来,确保自己看到的是最新的数据
Store屏障的作用是执行flush处理器缓存的操作,说白了就是把自己当前处理器更新的变量的值,都刷新到高速缓存(或者主内存)里去
在monitorexit指令之后,会有一个store屏障,让线程把自己在同步代码块里修改的变量的值都执行flush处理器缓存的操作,刷到高速缓存(或者主内存)里去,然后在monitorenter指令之后会加一个Load屏障,执行refresh处理器缓存的操作,把别的处理器修改过的最新值加载到自己高速缓存里来
所以说通过Load屏障和Store屏障,就可以让synchronized保证可见性
synchronized总结:
注意一点:volatile是不保证原子性的,特殊情况下,在32为的jvm中的long/double类型变量的赋值操作是不具备原子性的,加上volatile就可以保证原子性了
volatile boolean isRunning = true;
//线程1
//Release屏障
isRunning=false;
//Store屏障
//线程2
//Load屏障
while(isRunning){
//Acquire屏障
}
在volatile变量写操作的前面会加入一个Release屏障,然后在之后会加入一个Store屏障,这样就可以保证volatile写跟Release屏障之前的任何读写操作都不会指令重排,然后Store屏障保证了写完数据之后,立马会执行flush处理器缓存的操作
在volatile变量读操作的前面会加入一个Load屏障,这样就可以保证对这个变量读取时,如果被别的处理器修改过了,必须得从其他处理器的高速缓存(或者主内存)中加载到自己本地高速缓存中,保证读到的是最新数据,在之后会加入一个Acquire屏障,禁止volatile读操作之后的任何读写数据操作会跟volatile读指令重排序
volatile和synchronized
从原子性、可见性和有序性三个方面分别来聊,这两个关键字对那几个“性”的保障是通过什么来实现的,聊到他们会加哪些内存屏障,怎么加,这么内存屏障的效果,结合底层硬件层面的一个初步的原理,来给面试官聊一下
MESI的工作原理:
处理器0读取某个变量的数据时,首先会根据index、tag和offset从高速缓存的拉链散列表数据,如果发现状态为I,也就是无效的,此时就会发送read消息到总线
接着主内存会返回读经的数据给处理器0,处理器0就会把数据放到高速缓存里,同时cache entry的flag状态是s
在处理器0对一个数据进行更新的时候,如果数据转改是s,此时就需要发送一个invalidate消息到总线,尝试让其他的处理器的高速缓存的cache entry 全部变为I,以获得数据的独占锁
其他的处理器1会从总线嗅探到invalidate消息,此时就会把自己的cache entry设置为1,也就是过期掉自己本地的缓存,然后就是返回invalidate ack消息总线,传递回处理器0,处理器0必须收到所有处理器返回的ack消息
接着处理器0就会将cache entry先设置为E,独占这条数据,在独占期间,别的处理器就不能修改数据,因为别的处理器此时发出invalidate消息,这个处理器0是不会返回invalidate ack消息的,除非他先修改完再说
接着处理器0就是修改这条数据,接着将数据设置为M,也有可能是把数据此时强制写回到主内存中,具体看底层硬件实现
然后其他处理器此时这条数据的状态都是I了,那如果要读的话,全部都需要重新发送read消息,从主内存(或者是其他处理器)来加载,这个具体怎么实现要看底层的硬件了,都有可能
cache entry的flag代表了缓存数据的状态,MESI协议中划分为:
可见性和有序性问题:
可见性:是因为写缓冲期和无效队列导致的,写数据不一定立马写入自己的高速缓存(或者主内存),读数据不一定立马从别人的高速缓存(或者主内存)刷新最新的值过来,invalidate消息在无效队列里面
有序性:略~
因篇幅问题不能全部显示,请点此查看更多更全内容