4.1创建线程有哪几种方式?

创建线程一共有三种方式,分别是Thread类的子类,实现Runnable接口,和实现Callable接口。
【通过继承于Thread类并启动线程的步骤如下】:
(1)创建继承Thread类的子类,并重写run()方法,run()方法内的代码块为线程的执行体。
(2)调用子类对象.start()方法来启动线程
【通过实现Runnable接口并启动线程的步骤如下】:
(1)创建实现Runnable接口的实现类,并重写run()方法,run()方法内的代码块作为线程的执行体。
(2)创建Thread类的实例对象,将实现类的对象作为Thread的target行参。
(3)线程实例对象.start()方法来启动线程。
【通过实现Callable接口并创建线程的步骤如下】:
(1)创建实现Callable接口类的实例对象,并重写call()方法,call()方法内的代码块作为线程执行体,且call()方法具有返回值。
(2)创建FutureTask类的实例对象,并将Callable接口实现类的实例对象作为参数传给FutureTask类
(3)创建Thread类的实例对象,将FutureTask类的实例对象作为Thread的target参数传给Thread类,并调用线程对象.start()来启动线程
(4)如果需要获得返回值的结果,可以调用FutureTask实例对象.get方法来阻塞获取线程执行结果
实现Callable接口与实现Runnable接口的方式创建对象在本质上是相同的,只是Callable接口定义的call()方法有返回值,可以声明抛出异常。
【实现Callable与Runnable接口创建线程的优缺点】:
优点:
(1)当前类实现了接口的同时还可以继承其他类,可以打破java单继承的局限性。
(2)多个线程可以共享同一个target对象,适合用于多个线程逻辑异步、并行处理的场景,并且将执行逻辑与数据分隔开,体现了面向对象的思想。
缺点:
(1)代码稍微复杂一点,访问当前线程需要使用Thread.currentThread()。

4.2说说Thread类的常用方法

(1)构造方法有四种,无参,带String name,带Runnable target,带Runnable target和String name
(2)静态方法:
Thread.currentThread返回当前正在执行的线程。
Thread.sleep()带时间参数,使当前线程休眠多少毫秒。
Thread.interrupted()返回当前线程是否被打断的布尔值,并清除打断标记。
Thread.yield使当前线程放弃这次对cpu资源的使用,把cpu资源让给其他比它优先级更高的线程,如果没有其他线程获得此次cpu资源,那当前线程则继续执行。
(3)实例方法:
对象.getId getName 返回当前线程的id和名字;
对象.getPriority获取当前线程的优先级,默认是5;
对象.interrupt,打断该线程;
对象.isinterrupted返回该线程是否被打断的布尔值,不清除打断标记;
对象.inAlive()该线程是否存活;
对象.isDaemon()该线程是否是守护线程;
对象.setDaemon()设置该线程为守护线程;
对象.join()等待该线程执行结束,通常用于同步等待机制;
对象.join()带时间参数,等待该线程执行结束,最多等待多少毫秒数;

4.3run()和start()有什么区别?

(1)run()方法是线程的执行体,而start()方法是启动线程的方式。调用start()方法会默认调用线程对象的run()方法。
(2)如果run()方法不是因为调用了start()方法而被间接调用的,那这个run()方法就是一个普通方法,比如我用在main方法中调用了run()方法,那么真正执行这个run()方法的还是main线程,而且在run()方法结束之前其他线程无法并发执行。

4.4线程是否可以重复启动,会有什么后果?

只能对new状态的线程调用一次start()方法,如果调用多次会引发IllegalThreadStateException异常。当程序使用new关键字创建了一个线程的实例对象之后,这个线程就处于新建状态,此时这个线程对象和其他java对象一样,由java虚拟机为其分配内存,并初始化其成员变量的值。当调用线程.start()后,该线程由新建状态转化为可运行状态,java虚拟机为其创建方法调用栈和程序计数器,但是该线程不会立刻运行,只是表示该线程可以运行,至于如何运行取决于java虚拟机线程调度器的调度。

4.5介绍一下线程的生命周期

从操作系统的层面来说,线程的生命周期大体可分为5种状态,即新建、就绪、运行、阻塞、死亡
新建:当我们使用new关键字创建了一个线程类的对象,这个线程就处于新建状态,由java虚拟机为其分配内存空间,但此时这个对象与其他java对象一样,不表现出任何线程的动态特征,程序也不会执行线程执行体。
就绪:当我们调用线程对象.start()方法以后,就使该线程从新建状态转化为了就绪状态,java虚拟机为其分配方法调用栈和程序计数器,但该线程不会立刻得到运行,仅仅是表现为当前线程可以运行了,至于线程何时运行取决于java虚拟机线程调度器的调度。
运行:如果处于就绪状态的线程分到了cpu时间片,线程就由就绪状态转化为了运行状态。但一个线程不可能总是抢占着cpu资源,一旦cpu时间片用完就会发生线程上下文切换,cpu时间片再交给其他线程去执行它的线程执行体。如果在单核cpu的情况下,那在任何一个时刻只能有一个线程处于可运行状态。如果在多核cpu的情况下,多个线程并行执行。
阻塞:当调用Thread.sleep(),Thread类对象.join(),线程调用了阻塞式io方法,以及该线程没有抢到同步锁资源,都会使线程从运行状态转化为阻塞状态,当线程休眠时间到,等join()方法的那个线程运行结束或join()时间参数到,io方法执行完,当前线程获取到了同步锁资源,都可以结束该线程的阻塞状态,重新转化为就绪状态。
死亡:线程执行体的方法正常执行结束,当前线程就处于死亡状态了。或者是线程抛出一个未捕获的Exception或error导致线程猝死。

image.png

4.6介绍一下什么是守护线程

在java中有两类线程:用户线程与守护线程
(1)任何一个守护线程都是整个jvm中所有非守护线程的保姆。
(2)只要当前JVM事例中尚存在任何一个非守护线程没有结束,守护线程就都得一直工作;只有当最后一个非守护线程结束时,守护线程随着JVM一同结束工作。守护线程的作用是为其他线程的运行提供便利服务,守护线程最典型的应用就是垃圾回收器GC,它是一个很称职的守护者。
(3)用户线程与守护线程两者几乎没有区别,唯一不同在于虚拟机的离开,如果用户线程已经全部退出运行了,只剩下守护线程存在了,虚拟机也退出了。因为没有了被守护者,守护线程也就没有工作可做了,也就没有继续运行程序的必要了。
注意:
1.thread.setDaemon(true)必须在thread.start()之前设置,否则会抛出IllegalThreadStateException异常。只能在线程未开始运行之前设置为守护线程。
2.在守护线程中产生的新线程也是守护线程。
3.不要把所有的应用都分配给守护线程进行读写操作与逻辑计算,因为这可能会产生数据不一致的问题。

4.7如何实现线程同步?

1.同步方法:即有synchronized关键字修饰的方法,每个java对象都有一个内置锁(monitor),使用这个关键字修饰方法时,内置锁会锁住这个方法,在调用这个方法之前需要先获取到内置锁,如果有线程没有抢到内置锁,那那个线程就从运行状态转化为阻塞状态。当抢到内置锁的那个线程方法调用结束后就会释放锁资源,那些blocked状态的线程就可以重新去竞争内置锁资源,synchronized关键字也可以修饰静态方法,但会锁住整个类。
2.同步代码块:即有synchronized关键字修饰的代码块,需要传入monitor对象参数。被synchronized关键字修饰的代码块会被加上内置锁,与同步方法类似。需要注意的是使用synchronized关键字加锁是一种重量级高开销的操作,通常没有必要锁住整个方法,只需要锁住多个线程共同操作同一共享数据的那部分临界区代码即可。
3.使用java.util.concurrent包下的ReentrantLock类来实现同步,使用ReentrantLock与使用synchronized关键字这两种同步方式有很多相似之处,都是阻塞式同步的方式,即如果一个线程获得对象锁,其他线程都必须阻塞在外边等待,而进行线程阻塞和唤醒的代码是比较高的。需要注意的是使用ReentrantLock需要手动加锁和释放锁,所以需要搭配try/finally块来使用。
(1)此外ReentrantLock还实现了等待可中断,即持有锁的线程如果长期不释放锁,阻塞着的线程可以放弃等待,相对于synchronized来说可以避免出现死锁的情况;
(2)另外ReentrantLock还支持公平锁,即多个线程等待同一个对象锁时,需要按照申请锁的时间顺序来获得锁,synchronized锁是非公平锁,ReentrantLcok锁默认也是非公平锁,因为公平锁的性能表现不是很好。
(3)一个ReentrantLock对象可以同时绑定多个对象,ReentrantLock提供了一个Condition类,用来实现分组唤醒需要唤醒的线程们,而不像synronized那样那么唤醒一个线程notify()、要么唤醒所有线程notifyAll()。
(4)此外在性能上,在synchronized没有优化之前,synchronized的性能比ReentrantLock差很多,但自动synchronized引入了偏向锁、轻量级锁、自旋后,synchronized的性能就比ReentrantLock高了,如果在两种方法皆可使用的情况下,官方建议使用synchronized关键字。当然如果需要使用ReentrantLock的三个区别于synchronized的三个特性,那肯定还是使用ReentrantLock。
4.使用volatile关键字适用于读多写少情况,使用volatile关键字可以保证可见性和有序性,即保证指令不会受到cpu高速缓存和cpu指令并行优化的影响。使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。另外,volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。
1.可见性:如果不使用volatile关键字修饰共享变量,那么JIT即时编译器会将共享变量的值存入自己共享内存的高速缓存区,减少对主内存的访问,提供效率,使用volatile关键字使得多个线程只能通过访问主内存来获取值,虽然这样做效率会降低,但是可以保证共享变量在多线程间的可见性。
2.有序性:jvm在不影响正确性的前提下,可以调整字节码指令的执行顺序。因为现代CPU支持多级指令流水线,例如支持同时执行取指令-指令译码-指令执-数据写回的处理器,这时CPU可以在一个时钟周期内同时运行4条指令的不同阶段,相当于一条执行时间最长的复杂指令,本质上,流水线技术不能缩短单条指令的执行时间,但它变相的提高了指令的吞吐量。
底层实现是内存屏障,即对volatile变量的写操作后在volatile域写前后分别施加storeStore和storeLoad两个屏障,storeStore保证其之前所有的读写指令都执行完,不会与volatile写重排序,storeLoad保证将高速缓冲中的共享变量值刷新回主内存中去;对volatile变量的读操作会在voilatile域读前后分别施加LoadLoad和LoadStore两个屏障,LoadLoad保证让读取变量的操作一定是去主存中去读,将自己高速缓存区存储的变量值无效化,LoadStore禁止下面的所有的读写操作和上面的volatile读重排序
5.使用原子变量,使用volatile关键字可以保证线程间的可见性与有序性,但不能保证原子性,通常用于多个线程写读,一个线程写的场景,而使用原子变量,底层使用了volatile+cas乐观锁的机制来保证线程安全性。

4.8说说java多线程之间的通信方式

(1)wait/notify/notifyAll
如果线程之间采用synchronized的方式来保证线程安全性,就可以利用wait()\notify()\notifyAll()的方式来实现线程通信。这三个方法都不会Thread类中所声明的方法,而是Object类中声明的方法, 因为每个Object对象都拥有锁,所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作。
获得object对象锁的线程为owner线程,调用对象锁.wait()方法后,会使owner线程进入waitSet休息室等待,线程状态由Runnable状态变为Waiting状态,并释放对象锁。这时entrylist队列中的其他线程就可以竞争锁资源,进而得到cpu的执行权。notify()方法用于唤醒waitSet休息室中的单个线程, 并且是随机唤醒,使其进入entrylist队列,notifyAll()方法用于唤醒waitSet休息室中的所有线程,使其进入entrylist队列,重新竞争锁资源。
(2)await/signal/signalAll
如果线程之间采用Lock来保证线程安全性。则可以利用await/signal/signalAll的方式来实现线程通信,且这三个方法都是Condition类中的方法。ReentrantLock是AQS的子类,它的new Condition函数返回Condition的实例对象,是定义在AQS类内部的ConditionObject类。
使用这些方法比使用wait/notify能够更加安全、高效地实现线程间的通信,同时可以解决虚假唤醒问题。因为一个lock对象可以绑定多个Condition,调用一个condition对象的signal/signalall方法,只会唤醒该对象waitSet休息室中等待着的线程。
(3)BlockingQueue
虽然BlockingQueue是Queue的子接口,但它的主要用途不是容器,而是实现线程间的通信。例如典型的生产者消费者模型,如果生产者线程试图将元素放入BlockingQueue中,如果队列满,则该线程被阻塞;当消费者线程如果视图从BlockingQueue中取出元素,如果队列空,则当前线程被阻塞。程序的多个线程,通过交替从BlockingQueue中存放和取出元素,就可以很好的实现线程间的通信。

4.9说说java线程同步机制的wait/notify?

wait/notify/notifyAll必须配合synchronized关键字来使用,并且这三个方法是Object类中所声明的方法,因为每个Object对象都拥有自己的对象锁,所以让当前线程等待这个对象锁,必须通过这个对象来实现。
获取到对象锁的线程称为owner线程,如果调用对象锁的wait()方法后,这个owner线程就会进入waitSet休息室中等待,这个方法也可以设置时间参数,至多等待这么长时间,调用wait()方法后,owner线程会释放锁资源。而entrylist中的线程就可以重新竞争这个对象锁,当owner线程调用对象锁.notify()或者notifyAll()方法后,就会唤醒waitSet中的随机一个线程或者是所有线程,使他们进入entrylist去竞争锁。

4.10说说sleep()和wait()的区别?

(1)sleep()是Thread类的静态方法,而wait()是Object类中的成员方法
(2)调用sleep()方法时当前线程从Runnable状态转变为TimedWaiting状态,且这个操作不会释放锁资源;调用锁对象.wait()方法时当前owner线程进入waitSet队列中等待,线程状态由Runnable状态转变为Waiting状态或者,如果带时间参数则是TimedWaiting状态,这个操作会释放锁资源,entrylist中的等待线程有机会重新去竞争这个锁资源。

4.11说说notify\notifyAll的区别?

调用锁对象.notify()方法可以让waitSet中阻塞着的线程随机唤醒一个,让这个线程重新进入entrylist队列去竞争锁。
调用锁对象.notifyAll()可以让waitSet中所有阻塞着的线程唤醒,让这些线程重新进入entrylist队列中去竞争锁。

4.12如何实现子线程先执行,主线程再执行?

(1)启动主线程后,在main函数中调用Thread.slepp()方法,让主线程休眠一段时间,这个时间毫秒数主要可能不太好控制,所以不是很推荐,当然如此能够预估子线程执行体的执行时间,那使用sleep()也能达到效果。
(2)启动主线程后,立即调用该线程的join()方法,则主线程必须等待子线程执行完成后再执行。当在某个程序执行流程中调用了其他线程的join()方法后,调用线程将被阻塞,直到被join线程执行完线程执行体为止。
(3)可以使用FutureTask类和子线程实现callable()接口的方式来让子线程先执行,在main函数调用task.get()方法时会阻塞获取子线程的执行结果。

4.13说说synchronized和Lock的区别?

synchronized与lock都可以保证多线程操作共享数据的安全性,synchronized是一个关键字,lock是一个接口。
ReentrantLock作为Lock接口的主要实现类,其区别于synchronized关键字主要有三个特性:
1.可以设置公平锁:即多个线程同时等待锁资源时,按照申请锁的时间顺序来获取锁
2.一个Lock对象可以绑定多个Condition条件动态,ReentrantLock提供了一个Condition类,用来实现分组唤醒需要唤醒的线程们,解决虚假唤醒问题。
3.可以实现等待中断,如果一个线程长时间持有锁资源不释放,那么阻塞着的线程可以放弃等待,相对于synchronized来说,可以避免死锁。
ReentrantLock的底层基于AQS、LockSupport、操作系统的cas来实现,并保证线程安全。
ReentrantLock继承了Lock接口,成员属性Sync类继承自AQS,同时NonfairSync和FairSync类继承了Sync类。ReentrantLock底层通过AQS与LockSupport类来保证线程安全。
其中AQS本质上是一个阻塞式的同步框架,其作用是提供了加锁和释放锁的操作,内部维护一个等待队列FIFO,用于竞争锁失败的阻塞线程类似于monitor的entryList;同时可以绑定多个condition对象,用于实现和等待的唤醒,类似于monitor的waitSet;同时底层维护一个state变量,使用了volatile关键字来修饰,用于做锁状态的标记位。底层使用了cas原子操作来减少线程上下文切换,同时保证对共享变量操作的原子性、可见性、有序性。
ReentrantLock在采用非公平锁构造时,加锁实现首先通过调用compareAndSetState的方法来尝试把锁状态位由0修改为1,如果此次操作成功就调用setExclusiveOwnerThread()方法,把锁的持有者设置为当前线程。如果此时有竞争,那cas操作返回false,接下来进入可重入检查,如果owner线程恰好是当前线程说明发生了重入,就会把锁状态位+1;如果owner线程不是当前线程,就会进行几次自旋操作,如果都失败,就会进入addWaiter的逻辑,构造node队列,node节点的创建是懒惰的,其中链表的头结点是哨兵节点用来占位不关联线程,调用compareAndSetHead()方法保证保证只能有一个线程能够创建头节点成功;接着将当前线程封装成节点加入链表尾部。在将节点加入链表尾部的逻辑中也是使用了cas的方式,为了保证尾结点能成功插入链表采用了无限循环的方式。在入队列以后,进入acquireQueued()方法的逻辑,如果当前节点是老二节点,就有资格去尝试再次获取锁,就再次调用tryAcquire方法。如果这次又失败了,就进入shouldParkAfterFailedAcquire()的逻辑,将当前线程park住,在把当前线程park住之前还有个逻辑,需要把前驱节点的waitStatus状态设置为-1,表明前驱节点有义务来唤醒后继节点,因为如果前驱节点的状态不是-1自己就无法安心挂起,然后调用LockSupport类的park()方法将当前线程park住。
然后是释放锁的过程:
调用tryRelease()方法,调用tryRelease()方法的线程一定得是owner线程否则直接抛异常了。然后把锁的状态位-1,如果state变量不为0,还说明owner线程还持有着锁,只是释放了部分锁。如果此时锁的state状态为0,那就释放了全部的锁,调用setExclusiveOwnerThread()方法设置owner为null。释放成功后,就查看链表头结点的状态是否为-1,如果是则唤醒头节点的下个节点关联着的线程。其实这个过程我们也清楚每次都只会唤醒头结点的下个节点关联着的线程。

4.14说说synchronized的底层实现原理

【synchronized关键字作用在代码块时】
底层是使用monitorEnter和monitorExit指令来执行加锁和释放锁的逻辑的。每个java对象都能关联一个monitor,内部维护一个加锁计数器,当当前的monitor被占用时就会处于锁定状态。其他线程在执行获取锁的逻辑时,底层执行monitorEnter的执行,如果当前monitor的进入数为0,那就会把当前的monitor进入数+1,并把monitor的owner设为当前线程。如果当前monitor的进入数>0,就会检查执行monitorEnter的线程是否是owner线程,如果是就把锁的进入数再+1。如果不是owner线程,就无法执行线程执行体,由运行状态变成阻塞状态。直到owner线程释放完所有锁,monitor的进入数=0,其他线程才能由阻塞状态变为就绪状态,重新开始竞争锁资源。当synchronized的代码块执行完,就会执行释放锁的逻辑,底层是由monitorExit指令来执行的,执行monitorExit的线程必须是monitor的owner线程,释放完一次锁就把monitor的进入数-1,如果monitor的进入数减为了0,那就把owner线程置为null,如果减了以后monitor的进入数不为0,说明当前线程仍然持有着锁只是释放了部分锁。
【而synchronized关键字作用在方法时】
没有通过monitorEnter和monitorExit的指令来完成加锁和释放锁的逻辑。不过是相较于普通方法,常量池中多了一个ACC_SYNCHRONIZED标识符,jvm就是根据该标识符来实现线程同步的。当线程执行有ACC_SYNCHRONIZED标识符表示的方法时,需要先获得monitor锁,执行完方法后释放monitor。
其实本质上synchronized关键字作用在代码块与作用在方法中没有区别, 需要注意的是,作用在方法时会锁住整个方法,我们有时候并不需要锁住整个方法,而是需要锁住多线程操作共享变量的那部分逻辑即可。另外synchronized作用在方法时是一种隐式的方法来实现。monitorEnter与monitorExit的指令执行是由jvm调用操作系统的互斥语句mutex来实现,被阻塞的线程会挂起,等待重新调用,导致用户态和内核态之间的切换,对性能影响较大。

4.15synchronized可以修饰静态方法和静态代码块吗?

synchronized可以修饰静态方法,但不能修饰静态代码块。
当修饰静态方法的时,monitor锁是对象Class实例,因此静态方法锁相当于该类的一个全局锁。

4.16如果不使用synchronized和Lock,如何保证线程安全?

1.如果是多个线程读,单个线程写的情况,可以考虑使用volatile关键字来修饰共享变量,使用volatile关键字可以保证共享变量在多线程环境下的可见性与有序性。
可见性:如果变量不使用volatile关键字来修饰的情况,JIT即使编译器会将主存中多线程操作的共享变量的值读入到自己的工作内存中去(高速缓存区),因为这样做也可以提高执行效率,减少对主存的访问。而使用volatile关键字修饰后,一个线程对volatile变量执行完写操作后会同步到主存中去,并且让其他线程的工作内存中的volatile变量失效,即一个线程对共享变量的修改对其他线程可见。虽然这样做会使工作效率下降,但是可以保证共享变量在线程间的可见性。
有序性:jvm在不影响正确性的前提下,可以调整字节码指令的执行顺序。因为现代cpu支持多级指令流水线,比如同时完成取指令-指令译码-指令执行-内存访问-数据写回的操作,即cpu可以在一个时钟周期内同时运行五条指令的不同阶段,相当于执行了一条执行时间最长的复杂指令,本质上,流水线技术不能缩短单条指令的执行时间,但它变相的提高了指令执行的吞吐量。而volatile的底层实现是内存屏障,即对volatile变量的写指令后会施加写屏障,写屏障保障在该屏障之前对volatile变量的写操作都会同步到主存中去;对volatile变量的读指令前会施加读屏障,读屏障会保障在屏障之后的读操作都会从内存中去读取数据,加载的是主存中的最新数据。
2.使用原子变量,
在java.util.concurrent.atomic包下提供了原子类型变量的工具类,使用该类可以简化线程同步,其实现原理是cas的乐观锁机制。
3.本地存储,通过ThreadLocal类来实现线程本地存储的功能,每个线程对象都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的键值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含一个独一无二的键,使用这个值就可以在线程键值对中找回对应的本地线程变量。

4.17说说java中乐观锁和悲观锁的区别

悲观锁:总是以最坏的情况来考虑问题,认为多线程在操作同一共享变量的情况下一定会引发线程安全问题。与其等到发生了线程安全问题再去处理,不如事先就加锁,让同一时刻只能有一个线程能够访问共享变量,其他线程没有获得锁的线程都要阻塞等待。在java中悲观锁是通过synchronized关键或lock接口来实现的。
乐观锁:顾名思义,就是很乐观。每次去读写数据时都不会上锁,认为其他线程不会对数据进行修改,即使修改了也没有关系,我还有其他的应对办法。所以在写操作时会判断一下在此期间有没有其他线程更新了这个数据,如果有其他线程已经更新了共享变量,那再去执行修改失败时的逻辑。乐观锁适用于读多写少的应用场景,这样可以提高吞吐量。
其实现方式分别对应synchronized\reentrantLock\CAS。
cas(比较并替换)是乐观锁的一种实现方式,是一种轻量级的操作,juc很多的工具类都是基于cas的。compareAndSwap方法需要传递三个参数,要更新的变量,预期该变量的值以及需要修改的值。只有当v与e相等时,才能修改变量成功。如果失败,当前线程可以放弃此次操作或者多次尝试。线程在读取数据时不进行加锁,在写入数据时先去查询原值,操作的时候比较原值是否被修改过,若未被其他线程修改则写入,如果被修改则重新执行读取流程。Tips:比较与替换是一个原子操作,当然这个流程是存在很大问题的。因为要是做cas操作的时候如果原值一直被其他线程修改,就需要一直循环,cpu开销是一个问题,此外还存在aba问题和只能保证一个共享变量原子操作的问题。
aba问题的主要原因是由于当前线程在某一时刻从内存中取出共享变量的值以及在下一时刻使用cas原子操作尝试修改变量的值,中间的这段时间差值有其他其他线程已经修改变量了好几次共享变量,最终又把变量的结果改了回来。根本原因是线程在执行cas操作时不知道这个变量是否被修改过,已经修改了多少次,不清楚变量的状态。
aba问题就是:线程1读取数据a,线程2也读取了数据a,线程2通过cas操作把数据a更新成了数据b,线程3读取了数据b然后又把数据b改回了数据a,那线程1可不知道已经有两个线程悄悄已经把原本的数据修改过了,线程1读取原值的结果还是数据a,所以还是可以把数据a改成数据b。当然这种场景对结果本身没有什么影响,那我再举个例子。一个栈结构,自顶向下分别是元素12345,线程a的cas操作是判断如果栈顶元素是1就弹出栈顶元素,而此时线程b来直接把栈内元素都弹出,再push一个元素1。此外无锁并发和无阻塞并发必须依赖cas+volatile关键字,volatile关键字保证一个线程对共享变量的修改对其他线程可见。比如典型的原子整数AtomicInteger它的成员变量private volatile int value。想要解决aba问题,我们可以加上一个版本戳来设置标记位,那这时cas操作就需要多判断一次版本戳是否与我的期待的值一样,如果不一样就抛出异常。
cas的缺点:1.cas只能保证一个共享变量的原子操作。2.存在aba问题3.如果cas一直失败会空循环,开销大。
【然后悲观锁主要是chronized和ReentrantLock】
chronized是对对象进行加锁,在jvm中一个java对象在堆内存中由三部分组成:对象头、实例数据和对齐填充。
对象头:以HotSpot虚拟机为例,HotSpot的对象头主要包括两部分数据MarkWord(标记字段)、KlassPoniter(类型指针)
MarkWord:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间MarkWord里面存储的数据会随着锁标志位的变化而变化。
KlassPoint:如果使用synchronized给对象上锁以后,该对象的MarkWord就被设置指向monitor对象的指针?
在jdk1.6对synchronized引入了偏向锁、轻量级锁、自旋来优化synchronized。
image.png
最后是ReentrantLock,在介绍它之前必须先介绍AQS,队列同步器,这是实现ReentrantLock的基础。AQS有一个state位标记,值为0表示还没有线程占用,值=1的情况表示有线程占用了锁,其他线程需要到同步队列中等待,>1表示发生了锁重入。

4.18说说java中公平锁和非公平锁的区别

那先说说公平锁和非公平锁的定义吧
【公平锁】:多个线程按照申请锁的顺序去获得锁,线程会进入队列中去排队,但永远都是队列的首位才能获得锁。
优点:就是很公平嘛,多个线程都在队列中排队,都能获取锁资源,不会出现饿死的情况。
缺点:吞吐量会下降很多,队列里面的线程都会阻塞住,等待owner线程释放了锁后,会叫醒他队列中的第一位。而cpu唤醒阻塞线程对cpu的开销很大。
【非公平锁】:多个线程去获取锁时,会直接尝试去获取锁,如果获取不到就去同步队列中去排队,如果能获取锁,就直接得到。这一点对于队列第一名来说不公平的,但如果新晋线程没有获得到锁,进入了同步队列中排队去了,那还是公平的。
优点:可以减少cpu唤醒线程的开销,整体吞吐量对高点。
缺点:处理队列中后位的线程有可能一直获得不到锁或者长时间获取不到锁而饿死在队列中。
java中实现锁的方式有两种,一种是synchronized关键字对方法和代码块进行加锁,一种ReentrantLock。前者默认非公平,后者默认非公平但能设置为公平锁。
ReentrantLock是基于其内部类FairSync和NonFairSync实现的,并且它的实现依赖于队列同步器AQS,AQS使用一个带volatile关键字修饰的整型变量state来维护同步状态,其中公平锁与非公平锁的实现原理的区别主要在于tryAcquire这个方法,公平锁的tryAcquire方法首先会检查锁的state状态是否为0,以及是否队列中老二节点是否为空,或者队列的老二结点为当前线程,才去做cas操作修改state状态,并设置排它锁owner线程为当前线程。
非公平锁在实现的时候多次强调随机抢占,比如A线程是owner线程,A线程执行完操作后释放了锁,并唤醒了队列头节点b线程,此时冒出来一个e线程,看到锁的state状态位是0,就直接抢占了锁,这时b线程想要尝试获取锁就会发现state状态位为1,那只能又park住了。
非公平锁与公平锁的区别在于:新晋线程获取锁会有多次机会去抢占锁,但如果多次尝试失败后被加入了同步队列后就和公平锁没区别了。

4.19如何优雅地中断一个线程?

两阶段终止模式:使用volatile来实现
image.png

image.png

4.20了解java中的锁升级吗?

Jdk1.6以前,synchronized还有一个重量级锁,是一个效率比较低的锁。但是在jdk1.6以后,jvm提高了锁的获取和释放效率对synchronized进行了优化,引入了偏向锁和轻量级锁的概念。此后锁的状态就有了四种:无锁、偏向锁、轻量级锁、重量级锁。并且四种状态会随着竞争的情况逐渐升级,而且过程不可逆(不可降级)。
在介绍锁升级之前,还需要先介绍一下java对象头,因为synchronized锁是存在java对象头里的,以HotSpot虚拟机为例说明,HotSpot对象头主要包括两部分数据:MarkWord(标记字段)和KlassPointer(类型指针)。
MarkWord:默认存储该对象的hashCode,分代年龄和锁状态位信息。在运行期间MarkWord里面存储的数据会随着锁标志位的变化而变化。
KlassPointer:是对象指向它的类元数据的指针,虚拟机通过这个指针来判断这个对象属于哪个类的实例对象。再来看看MarkWord的字节是具体如何分配的,以32位虚拟机为例:
无锁:对象头25位空间存储对象的hashCode,4位存储分代年龄,1位存储是否偏向锁的标志位,2位存储锁表示位01.
偏向锁:在偏向锁中划分更细,25位原本存储哈希码的信息换成为23位存储线程id以及2位存储epoch,4位存储分代年龄,1位存储是否偏向锁(0表示无锁,1表示偏向锁),锁的标识位还是01.
轻量级锁:在轻量级锁中开辟30位空间存放指向栈中锁记录的指针,2位存放锁的标志位,标志位为00.
重量级锁:重量级锁和轻量级锁差不多,30位存放栈中锁记录指针,2位存放锁的标志位为10.
GC表示:30位内存空间不占用,2位存放锁标志位11.
无锁:对象头中的锁标志位为01,是否偏向锁为否。指当前还没有线程进入同步代码块,所以还没偏向于哪个线程, 当有一个线程访问了同步代码块时,就会升级为偏向锁。
4.juc - 图5
【自旋锁】
(1)重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(就是这时候持有锁的线程已经退出同步块释放了锁),这时当前线程就可以避免阻塞了。在java6以后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功后,那么认为这次自旋成功的可能性会很高,就多自旋几次;反之就少自旋甚至不自旋,总之比较智能。另外,自旋会占用cpu时间。
【轻量级锁】
image.png
(1)引入轻量级锁的目的:在多线程交替执行同步块,并且没有发生竞争的的情况下,尽量避免重量级锁使用的操作系统互斥量带来的开销,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁升级为重量级锁,所以轻量级锁的出现并非是要替代重量级锁。
(2)轻量级锁的获取流程: 1. 获取对象的markword 2. 查看对象是否为无锁状态001 3. 如果锁对象是无锁状态,在线程栈帧中创建一个LockRecord对象(包含两部分:锁记录地址以及object Reference,将object reference指向锁对象,并尝试用cas原子操作替换锁对象的markword,将markword存入自己的lockRecord;如果cas替换成功,对象头存储了锁记录地址和状态00,表示给锁对象加了轻量级锁。4. 如果cas失败,有两种情况:1. 如果是自己执行了synchronized锁重入,那么再添加一条LockRecord作为重入的计数,其中的锁记录地址的值为null。 2. 如果其他线程已经持有了该轻量级锁,这时说明有竞争,进入锁膨胀过程。这个线程会为这个object对象申请monitor锁,让object对象的Monitor地址指向Monitor对象(低2位10),然后自己进入monitor的entryList里去blocked。
(3)轻量级锁的释放流程: 1. 当退出synchronized代码块时,如果锁记录的值不是null,这时使用cas将markword的值恢复给对象头。如果成功,则解锁成功;如果失败,说明轻量级锁进行了锁膨胀已经升级为重量级锁,进入重量级锁的解锁流程。根据Monitor地址找到monitor对象,设置owner为null,唤醒entryList中的阻塞线程。
【偏向锁】
(1)引入偏向锁目的:在没有多线程竞争的情况下,尽量减少不必要的轻量级锁的执行。因为轻量级锁发生锁重入的时候,也是需要一次cas操作的。轻量级锁的获取及释放依赖多次cas原子指令,而偏向锁只依赖一次cas原子指令。但是在多线程竞争时,需要进行偏向锁撤销步骤,因此撤销的开销必须小于节省下来的cas开销,否则偏向锁不能带来收益。Jdk1.6默认开启偏向锁,当然也可以禁用偏向锁。如果在多线程并发竞争激烈的时候,偏向锁不仅不能提高性能,还是大大降低,所以jdk15默认不开启偏向锁。
(2)匿名偏向状态:锁对象的mark word标志位为101,但存储的线程id为空的状态。(即锁对象为偏向锁,但还没有线程偏向于这个锁对象。设置这个匿名偏向状态的理由是:只有锁对象处于匿名偏向状态,线程才能拿到通常意义上的偏向锁,而处于无锁状态的锁对象,只能进入到轻量级锁状态。偏向锁是延时初始化的,默认延时时间是4000ms,就是说在程序启动时我们创建的所有对象默认都是无锁状态,对于无锁状态的锁对象,如果有线程获取锁,会直接进入到轻量级锁的加锁流程。
(3)偏向锁加锁只需要一次cas操作是因为当判断monirot对象的第三位是101时,会进入加偏向锁的逻辑,这一次cas操作就是将monitor对象的前23位线程id替换成自己的threadId,如果cas操作成功的话,那该monitor锁就偏向于当前线程,如果cas操作失败的话,说明该锁已经偏向于其他线程,就会进入升级成轻量级锁的过程。
(4)批量重偏向:如果某个锁对象前后被多个线程访问,但是没有竞争关系。这时偏向锁如果偏向了线程T1,但让仍然是有机会重新偏向为线程T2的,重偏向会重新设置monitor对象的ThreadId。因为偏向锁的撤销也是会耗费性能的,当撤销偏向锁的阈值超过20次后,jvm会觉得,我是不是偏向错了,应该偏向给另一个线程。
(5)批量撤销:当撤销偏向锁的阈值超过40次后,jvm会觉得自己偏向错的,根据就不该偏向。
(6)可以根据VM参数配合或者调用hashcode()时会禁用掉偏向锁,因为对象头只有32位或者64位,没有太多的空间既存储线程id又存储对象的哈希码。另外在synchronized中调用wait/notify时,不管是偏向锁还是轻量级锁,都会升级为重量级锁,因为重量级锁才有这些操作。
(7)偏向锁的撤销会导致stw,偏向锁的撤销需要等待全局安全点,暂停持有偏向锁的线程,检查持有偏向锁的线程状态。首先遍历当前JVM所有的线程,如果能找到偏向线程,则说明偏向的线程还存活,此时检查线程是否在执行同步代码块中的代码,如果是,则升级为轻量级锁。

4.21说说你对读写锁的了解

与传统锁不同的是读写锁的规则是可以共享读,但只能一个写,总结起来就是:读读并发、读写互斥写写互斥,而一般的独占锁是:读读互斥、读写互斥、写写互斥,而很多场景中往往读大于写,读写锁就是为了这种优化而创建出来的一种机制。读写锁的内部维护了了一个ReadLock和一个WriteLock,它们依赖Sync实现具体功能,Sync继承自AQS,而AQS内部维护了一个volatile同步状态state,ReentrantReadWriteLock使用state高16位表示读状态,低16位代表写状态;分别代表获取读锁的获取次数以及线程发生写锁重入的次数。
【写锁的获取流程】:tryAcquire()
(1)如果当前AQS的同步状态不为0,说明已经有线程获取了读锁或者写锁。然后判断低16是否为0,如果为0表示当前虽然没有线程获取写锁,但是已经有线程获取了读锁,则返回false;如果不是0,则说明当前有线程已经获取到了写锁,再看当前线程是否是写锁的持有者,如果不是则返回false。
(2)如果是当前线程已经是写锁的持有者,表示这是一次重入操作。如果超过了重入的最大值则抛出异常,否则增加当前写锁的可重入次数。
【写锁的释放流程】:tryRelease()
(1)写锁在释放的时候,会使用CAS操作将地位的State状态-1,如果重入次数为0,则将写锁的Exclusive线程设为null,然后去阻塞队列中唤醒阻塞的线程,如果唤醒的是Shared节点,那还会继续唤醒它的后继的Shared节点,直到后继节点为Exclusive节点。如果阻塞队列的老二节点是Exclusive节点,那就只会唤醒这一个节点。
【读锁的获取流程】:tryAcquireShared()
(1)首先获取AQS的同步状态,然后判断写锁是否被占用,如果被占用且不为当前线程则返回-1,然后调用doAcquireShared()把当前线程放入AQS阻塞队列中。
(2)如果当前要获取读锁的线程已经持有了写锁,那么也可以再次获取读锁。然后在读事件处理完成后,既释放读写又释放写锁。
注意是读远远大于写,一般情况下独占锁的效率低来源于高并发下对临界区的激烈竞争导致线程上下文切换。因此当并发不是很高的情况下,读写锁由于需要额外维护读锁的状态,可能还不如独占锁的效率高。所以需要根据实际情况选择使用。
在java中ReadWriteLock的主要实现为ReentrantReadWriteLock,其提供了以下特征:
(1)公平性选择:支持公平与非公平(默认)的锁获取方式,吞吐量非公平优于公平。
(2)可重入:读线程获取读锁之后可以再次获取读锁,写线程获取写锁之后也可以再次获取写锁。
(3)可降级:写线程获取写锁之后,其还可以再次获取读锁,然后释放掉写锁,那么此时该线程也是读锁状态,也就是降级操作。

4.22谈谈你对JUC的了解

JUC是java.util.concurrent的缩写,juc是java提供的并发包,其中包含了一些并发编程用到的基础组件。juc这个包下的类基本上包含了我们在并发编程时用到的一些工具,大致可以分为以下几类:
(1)原子更新
java从jdk1.5开始提供了java.util.concurrent.atomic包,方便程序便在多线程环境下,无锁得进行原子操作。在Atomic包里一共有12个类,四种原子更新方式,分别是原子更新基本类型,原子更新数组,原子更新引用和原子更新字段。
(2)锁和条件变量
java.util.concurrent.locks包下包含了同步器的框架AbstractQueuedSynchronizer,基于AQS构建的Lock以及Lock配合可以实现等待/通知模式的Condition。JUC下大多数工具类用到了Lock和Condition来实现并发。
(3)线程池
涉及到的类比如:Executor、Executors、ThreadPoolExecutor、AbstractExecutorService、Future、Callable、ScheduledThreadPoolExecutor等等。
(4)阻塞队列
涉及到的类比如:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、LinkedBlockingDeque等等。
(5)并发容器
涉及到的类比如:ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue、CopyOnWriteArraySet等等。
(6)同步器
剩下的是一些在并发编程时经常会使用到的工具类,主要用来协助线程同步。比如:CountDownLatch、CyclicBarrier、Exchanger、Semaphore、FutureTask等等。

4.23谈谈你对AQS的理解

(1)抽象队列同步器AbstractQueuedSynchronizer,它是用来构建锁以及其他同步组件的骨架类,减少了各功能组件实现的代码量,达到代码复用的效果,也解决了在实现同步器涉及到的大量细节问题。
(2)AQS是一个FIFO的双向队列,内部通过节点head和tail记录队列中的队首和队尾元素,队列元素的类型为Node,其中Node就是将线程封装成Node节点然后放入队列中去。Node节点内部的Shared用来标识该线程是获取共享资源时被阻塞的挂起后放入AQS队列中的;Exclusive用来标识线程是获取独占资源时被挂起后放入到AQS队列中的;waitStatus记录当前线程等待状态,可以为Cancelled(线程被取消了)、Signal(线程需要被唤醒)、Condition(线程在条件队列中等待)、Propagate(释放共享资源时需要通知其他节点);prev记录当前节点的前驱节点,next记录后继节点。
(3)AQS中维护了一个单一的状态信息state,可以通过getState、setState、compareAndSetState函数修改其值。对于ReentrantLock来说,state可以用来表示锁的进入次数;对于读写锁ReentranReadWriteLock来说,state的高16位表示读状态,也就是获取读锁的次数;低16位表示写锁的重入次数;对于Semaphore来说,state表示当前可用信号的个数;对于CountDownLatch来说,state表示当前计数器的值。
(4)AQS有个内部类ConditionObject,可以结合锁来实现线程同步。ConditionObject是一个条件变量,一个条件变量对应一个条件队列(单向链表队列),它用来存放调用条件变量的awate方法被阻塞的线程。
(5)对于AQS来说,线程同步的关键是对state进行操作。根据state是否属于一个线程,操作state的方式分别独占模式和共享模式。在独占模式下获取锁和释放锁的方法是void acquire、void acquireInterruptibly、boolean release。对于共享模式则是void acquireShared…
(6)使用独占模式获取锁是与具体线程绑定的,如果一个线程获取到了锁,就会标识是这个线程独占了,其他线程再尝试操作state获取资源时就会失败被阻塞。比如当一个线程获取到ReentrantLock锁后,在AQS内部首先会使用CAS操作把state状态值从0变为1,然后设置当前锁的持有者线程为当前线程。当该线程再次获取锁时,state就会变成2。而当另外一个线程尝试获取锁时,发现它不是锁的持有者就会放入AQS阻塞队列中挂起。
(7)如果是继承自AQS实现的读写锁ReentrantReadWriteLock,在获取读锁时首先会检查是否写锁被其他线程持有,如果是则失败,如果不是则使用cas的方式操作state的高16位状态;在获取写锁时,首先会检查读锁以及写锁是否被其他线程持有,如果是则失败,如果不是则使用cas的方式操作state的低16位状态。
AQS采用模板方法模式,在内部维护了n多的模板的方法的基础上,子类只需要实现特定的几个方法,就可以实现子类自己的需求。
基于AQS实现的组件,比如:
(1)ReentrantLock可重入锁(支持公平和非公平的方式获取锁)
(2)Semaphore技术信号量
(3)ReentrantReadWriteLock读写锁
AQS内部维护了一个int成员变量来表示同步状态,通过内置的FIFO(first-in-first-out)同步队列来控制获取共享资源的线程。
AQS其实主要做了这么几件事情:
(1)同步状态state的维护管理;
(2)等待队列的维护管理;
(3)线程的阻塞和唤醒;
通过AQS内部维护的int型的state,可以用来表示任意状态。
(1)ReentrantLock用它来表示锁的持有着线程已经重复获取该锁的次数,而对于非锁的持有着线程来说,如果state大于0意味着无法获取锁,将该线程包装为Node,假如到同步等待队列里。
(2)Semaphore用它来表示剩余的许可数量,当许可数量为0时,对未获取到许可但正在努力尝试获取许可的线程来说,会进入同步等待队列阻塞,直到一些线程释放掉持有的许可(state+1),然后竞争使用释放掉的许可。信号量,用来限制能同时访问共享资源的线程上限。共享资源允许多个线程来访问,但必须对访问的线程上限加以限制。Semaphore有点相当于是停车场,permits就好像停车位数量,当线程获得了permit就像获得了一个停车位,然后停车场显示空余车位减一,获取失败的线程进入AQS队列park阻塞。
(3)FutureTask用它来表示任务的状态(未开始、运行中、完成、取消)
(4)ReentrantReadWriteLock在使用时,稍微有些不同,int型state用二进制表示是32位,前16位(高位)表示读锁,后面的16位(低位)表示写锁。
(5)CountDownLatch使用state表示计数次数,state大于0,表示需要加入到同步等待队列并阻塞,直到state等于0,才会逐一唤醒等待队列里的线程。CountDownLatch用来进行线程同步协作,等待所有线程完成倒计时。其中构造参数用来初始化等待计数值,await()方法用来等待计数归零,countDown()用来让计数减一。就好像是打王者荣耀,在游戏开始的时候需要等待所有的用户都加载完毕,加载完成的用户让state的数值减为0,当state的数值为0的时候被阻塞的线程就可以开始运行了。

  1. public static void main(String[] args) throws InterruptedException {
  2. CountDownLatch countDownLatch=new CountDownLatch(10);
  3. ExecutorService service = Executors.newFixedThreadPool(10);
  4. String[]all=new String[10];
  5. Random random=new Random();
  6. for(int j=0;j<10;j++) {
  7. int k=j;
  8. service.submit(new Runnable() {
  9. @Override
  10. public void run() {
  11. for (int i = 0; i <= 100; i++) {
  12. try {
  13. Thread.sleep(random.nextInt(100));
  14. } catch (InterruptedException e) {
  15. e.printStackTrace();
  16. }
  17. all[k] = i + "%";
  18. System.out.print("\r" + Arrays.toString(all));
  19. }
  20. countDownLatch.countDown();
  21. }
  22. });
  23. }
  24. countDownLatch.await();
  25. System.out.println("游戏开始");
  26. }

AQS通过内置的FIFO同步队列来控制获取共享资源的线程。CLH队列是FIFO的双端双向队列,AQS的同步机制就是依靠这个CLH队列完成的。队列的每个节点,都会前驱节点指针和后继节点指针。
image.png

4.24LongAdder解决了什么问题,它是如何实现的?

高并发下计数,一般最先想到的应该是AtomicLong/AtomicInteger,AtomicXXX使用硬件级别的指令CAS来更新计数器的值,这样可以避免加锁,机器直接支持的指令,效率也很高。但是AtomicXXX中的CAS操作在出现线程竞争时,失败的线程会白白地循环一次,在并发很大的情况下,因此每次CAS都只有一个线程能成功,竞争失败的线程会非常多。失败次数越多,循环次数就越多。很多线程的CAS操作越来越接近自旋锁。计数操作本来是一个很简单的操作,实际需要耗费的cpu时间应该是越少越好,AtomicXXX在高并发计数时,大量的cpu时间都浪费在自旋上了,这也降低了实际的计数效率。
LongAdder是jdk8新增的用于并发环境的计数器,目的是为了在高并发情况下,代替AtomicLong和AtomicInteger,成为一个用于高并发情况下的高效通用计数器。LongAdder是根据锁分段来实现的,它里面维护了一组按需分配的计数单元,并发计数时,不同的线程可以在不同的计数单元上进行计数,这样减少了线程竞争,提高了并发效率,本质上是用空间换时间的思想,不过在实际高并发情况下消耗的空间几乎可以忽略不计。
现在,在处理高并发计数时,应该优先选用LongAdder,而不是继续使用AtomicLong。当然,线程竞争很低的情况下进行计数,使用Atomic还是更简单直接,并且效率稍微高一些。其他情况,比如序号生成,这样情况需要准确的数值,全局唯一的AtomicLong才是正确的选择,此时就不应该使用LongAdder。

4.25请介绍一下ThreadLocal和它的应用场景

ThreadLocal提供了线程的局部变量,每个线程都可以通过set()和get()来对这个局部变量进行操作,并且不会和其他线程的局部变量产生冲突,实现了线程的数据隔离。
应用场景:
(1)管理Connection
最典型的就是用ThreadlLocal管理数据库连接池中的连接:因为ThreadLocal能够实现当前线程的操作都是用的同一个Connection,保证了事务!首先频繁的创建和关闭Connection是一件非常耗资源的操作,因为需要创建数据库连接池。另外连接池是缓存并托管数据连接,主要是为了提升性能。而使用ThreadLocal,缓存连接,可以保证每个线程都在使用自各的Connection进行对数据库的操作,不会出现A线程关了B线程正在使用的Connection
(2)在单体项目中,就使用了ThreadLocal用来存储每个用户的信息。在preHandle前,根据请求的cookie登录凭证来判断用户是否登陆,以及凭证是否过期,如果符合以上条件就把这个用户的信息用ThreadlLocal保存起来,并构建用户的认证结果,因为用户的身份有普通会员、管理员和版主,所以不同身份有不同的权限,把他们存入SecurityContext中,以便于Security进行授权。在postHandle中,将ThrealLocal中存储的当前User对象取出并传递给modelAndView。在模板引擎执行完以后,将ThreadLocal中的User数据清理掉,以免发生内存泄漏。
(3)Kryo,Kryo是一种快速高效的java对象序列化框架,Kryo是目标是构建快速、效率、易于使用的API。当对象需要持久化,无论是用于文件、数据库还是通过网络,效率都非常高。但是Kryo使用Output和Input类完成数据输入和输出,这两个类不是线程安全的。每个现场都应该有自己的Kryo对象、Input和Output实例。因为Kryo的创建/初始化是相当昂贵的,所以在多线程的情况下应该池化Kryo实例。一个非常简单的解决方案是使用ThreadLocal,将Kryo实例直接绑定到ThreadLocal中。
【set()】:
1.先获取当前线程
2.判断当前线程ThreadLocalmap是否为空
3.如果为空就创建一个threadLocalMap,key为当前threadLocal对象,值为要设置的值。
4.如果不为空就调用threadLocalMap中的set方法
【get()】:
1.先获取当前线程
2.判断当前线程ThreadLocalMap是否为空
3.如果为空就为它创建一个ThreadLocalMap,key为当前threadLocal对象,值为null
4.如果threadLocalMap不为空,那就获取当前以threadLocal为key的Entry键值对
5.如果这个entry为空,就调用setInitialValue()
【remove()】:
1.获取当前线程
2.获取当前线程的threadLocalMap
3.如果map不为空,删除以当前threadLocal为key的entry.
在Thread类中有个静态类内部类ThreadLocalMap,同时也是它的成员变量,这个类没有实现Map接口,就是一个普通的Java类,但是实现的功能类似于Map,每个线程都拥有自己的一个map,这个map以entry[]的数据结构来储存元素,Entry的key存储ThreadLocal的引用,value是要set的值。
这样设置的好处:
1.当线程销毁的时候,ThreadlLocalMap也随之销毁,减少内存的使用。
2.每个Map存储的Entry数量变少,减少哈希冲突。
其中的Entry继承于WeakReference,用一个键值对存储,为什么要使用弱引用呢,目的是为了将TheadlLocal对象的生命周期与线程的生命周期解绑,这就涉及到了内存泄漏的问题。
内存泄漏:程序中动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢,甚至系统崩溃等严重后果,内存泄漏最终将导致内存溢出。
强引用:
5f8ae4c6dd8644ed15de1e0e950794b.png
弱引用:
270cb149773685ef9f4c210ea3e4715.png
那为什么要设置Entry的key是弱引用呢?
b8bbff53b31ff24b7ae1662eec31fdb.png
那么如何处理哈希冲突的问题的呢?
首先在调用threadLocal.set()方法初始化会对当前线程的ThreadLocalMap的判空,如果map为空,就先初始化这个map,调用ThreadLocalMap的构造方法,初始化map的时候首先会创建长度为16的table数组,计算当前传入k的桶下标,然后设置size为1,扩容阈值为初始容量的2/3,重点的计算firstKey的索引这一步,ThreadLocal的哈希算法,其底层是使用了一个原子整数AtomicInteger,并调用它的getAndAdd方法,追加参数是一个固定的16进制值,其主要目的是为了让哈希码能够均匀的分布在2的n次方的数组中,也就是Entry[]table,这样做可以尽量避免哈希冲突的算法。计算桶下标:【hashCode&(size-1)】这其实就相当于哈希值%size的一个更高效的实现。正是因为这种算法,要求size必须是2的整次幂,这也能保证索引不越界的情况下,使用hash冲突的次数减少。
此外如果发现哈希冲突时,是采用线性探测法的方式来解决哈希冲突带来的问题,首先还是计算出当前key的桶下标,然后找到这个索引位置上的Entry,进行条件判断,调用两者的哈希code方法如果返回true,用新值替换旧值;如果取出的key为null,但是值不为null,这就说明之前的ThreadLocal对象已经被回收了,那就直接用新元素替换原来的元素,利用这个方法也可以防止内存泄漏;如果返回false,进行下个桶的探测,直到遇到为null的地方,这时候如果还在循环过程中就会在这个null的位置上新建一个entry并且插入,同时size+1。最后调用cleanSomeSlots方法(),清理key为null的Entry,接下来判断size是否大于阈值,如果达到的话就会调用rehash函数进行一次全表的扫描清理。该方法依次探测下一个地址,直到有空的地址可以插入,如果整个空间就找不到空余的地址,就会产生溢出。
举个例子,假如当前table长度为16,如果计算出来的key的hash值为14,如果table[14]上已经有值,并且其key与当前key不一致,就会将14+1得到15,取table[15]进行判断,如果还是冲突则回到[0],依次类推直到可以插入。按照这种算法,可以把Entry[]table看作是一个环形数组。

4.26请介绍一下线程池

在实际开发中,服务器在创建和销毁线程上花费的时间和消耗的系统资源都相当大,甚至可能比处理用户请求耗费的时间和资源更多。除了创建和销毁线程的开销以外,活动线程也是需要消耗系统资源的。而线程池主要是用来解决线程生命周期开销问题和资源不足问题,通过对多个任务重复使用线程。在请求到达时服务器端时,因为线程已经存在,所以可以省去创建线程的延迟,这样就可以立刻为请求作出响应,使应用程序响应更快。
与数据库连接池类似的是,线程池在系统启动时就创建了大量空闲的线程,程序将一个runnable对象或者callable对象传给线程池,线程池就会启动一个空闲的线程去执行任务。当任务执行完成以后,该线程不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个任务。
Java5新增了一个Executors工厂类来产生线程池,该工厂类主要包含几个静态方法来创建线程池,创建出来的线程池,都是通过ThreadPoolExector类来实现的。
(1)new FixedThreadPool:其特点是核心线程数=最大线程数,没有救急线程被创建,因为也无需设置应急线程的存活时间。另外因为阻塞队列是无界的,可以放任意数量的任务,适用于任务已知,相对耗时的任务。
(2)new CachedThreadPool:其特点是核心线程数为0,最大线程数为Integer.maxValue,意思全部都是应急线程。每次提交一个任务,可以重用之前已创建的可用线程,如果线程池中不存在可用线程,就会创建一个新的线程加入到线程池中。如果线程超过60s后还未被使用,就会从缓存中移除这个线程。这个线程池在执行大量短生命周期的异步任务时可以显著提高程序性能。
(3)new SingleThreadPool:创建只有一个核心线程的线程池。它与我们自己手动创建一个新的线程执行任务的区别在于,如果线程池中的这唯一的线程执行任务失败抛出异常了, 还会创建一个新的核心线程把原来的线程替换掉,原来的线程将被销毁。与new FixedThreadPool()设置参数为1的区别在于,它对外只暴露了Exectuor接口,不能调用ThreadPoolExecutor中的特有方法,比如设置核心线程数等等。使用场景是:希望多个任务排队执行,处理按顺序执行的任务。new ScheduledThreadPool:特点是核心线程数可设置,救急线程数为Integer.MaxValue。如果应急线程还在10s内没有执行任务就会被回收,释放速度快。应用场景是:可以处理定时任务,或处理固定周期的重复任务,执行完后尽可能少的占用资源。

4.27介绍一下线程池的工作原理

(1)当接收到一个任务时,判断当前线程池是否有空闲的线程,如果有空闲的线程去执行这个任务。
(2)如果核心线程都正在执行着任务,就会查看任务队列是否满,如果任务队列没满就把这个任务添加到任务队列中去,等待线程执行完当前任务以后再来任务队列中取
(3)如果任务队列也满了,并且设置了救急线程,那就会创建一个救急线程来执行当前的任务。
(4)如果线程池中的核心线程都在执行着任务,并且队列队列也满了,救急线程也正在执行着任务,就会启动拒绝策略。
image.png

4.28线程池都有哪些状态?

Running状态:能够接收新任务,并且能够处理阻塞队列中的任务。
ShutDown状态:不会接收新任务,但是会把阻塞队列中的任务处理完。如果线程池处于running状态,调用shutdowun()方法会使线程池进入shutdown状态。
Stop状态:不会接收新任务,会中断正在执行的任务,并且抛弃阻塞队列中的任务。如果线程池状态处于running或者shutdown状态,调用shutDownNow()方法会使线程池进入stop状态
Tidying状态:如果所有的任务都执行完,并且活动线程为0;线程池进入该状态后会调用terminated()方法进入Terminate状态。

4.29谈谈线程池的拒绝策略

就是当线程池中的任务队列已满,并且线程池中的线程数目达到设置的最大线程数,如果还有任务到来的话,就会开启任务拒绝策略。通常有以下四种策略:
1.丢弃此任务并抛出异常。
2.由调用的线程自己来处理这个任务。
3.丢弃此任务但不抛出异常。
4.丢弃队列最前面的任务,把这个任务添加到队列最前面。

4.30线程池的大小你通常怎么设置?

这个问题得具体情况具体分析吧。如果队列过小,会导致程序不能充分利用系统资源,容易导致饥饿;如果过大会导致更多的线程上下文切换,占用过多内存。根据任务的性质可以分为CPU密集型任务和IO密集型任务。
(1)CPU密集型任务
计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,尽量使用较小的线程池,一般为cpu核心数+1。因为cpu密集型任务使得cpu使用率很高,如果开过多的线程数,会造成线程上下文切换的频率较快。
(2) IO密集型任务:
涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,基本不怎么占用CPU,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。常见的大部分任务都是IO密集型任务,比如Web应用。可以使用稍大的线程池,一般为2cpu核心数+1,io密集型任务cpu使用率并不高,因为可以让cpu在等待io的时候有其他线程可以去处理别的任务,充分利用cpu时间。
(3)混合型任务:
将任务分成io密集型任务和cpu密集型任务,分别用不同的线程池去处理。只要分完以后两个任务的执行时间相差不大,那么就会比串行执行来的高效。如果划分以后两个任务的执行时间有很大差距,那这个拆分就没有什么意义了,因为先执行完的任务要等待后执行完的任务,最终的任务执行时间还是取决于后执行完的任务,而且还要加上任务拆分与合并的开销,就得不偿失了。
(4)如何合理估算线程池的大小?
根据经验公式,最佳线程数目=【(线程等待时间+线程CPU使用时间)/线程CPU使用时间】
CPU核心数目
比如每个线程平均的CPU使用时间是0.5s,线程等待时间(非CPU运行时间,比如IO阻塞)的时间为1.5s,CPU核心数为8,线程池的最佳线程数可以设置为(0.5+1.5)/0.5*8=32;
(5)Tomcat线程池参数的设置
Jdk提供了线程池,Tomcat之所以还自定义线程池,是因为jdk提供的线程池是基于cpu密集型的;而Tomcat处理的请求大多数都是网络IO相关的,如果核心线程满了,就入队列的话,那很多请求就会一直在队列中被阻塞,所以Tomcat的线程池策略是当线程个数达到了最大线程数的时候才会将请求入队列。
【Tomcat线程池的参数】:
(1)maxConnections:tomcat最大连接数;
(2)maxThreads:每一次Http请求到达web服务器,tomcat都会创建一个线程来处理该请求,那么最大线程数决定了Web服务器决定了可以同时处理多少个请求。maxThreads默认是200,这个可以根据主机服务器的配置来适当增加最大线程数。但是增加线程是有成本的,更多的线程不仅仅会带来更多的线程上下文切换成本,而且意味着带来更多的内存消耗。JVM默认情况下在创建新线程时会分配1M的线程栈,所以,更多的线程意味着需要更多的内存。线程数的经验值为:1核2gb内存为200,4核8gb为800。
(3)maxIdleTime:最大空闲时间,超过这个空闲时间,线程数大于最小空闲空闲数的线程都会被回收。
(4)maxSpareThreads:最小空闲线程数,无论如何都会存活的线程数目;
(5)maxQueueSize:线程池中TaskQueue的长度,默认是Integer.MaxValue。

4.31线程池有哪些参数?

线程池一共有7个参数
corePoolSize:核心线程数量
maximumPoolSize:最大线程数
keepAliveTime和TimeUnit:这两个参数主要是针对救急线程来使用的,设置空闲的救急线程最大存活时间,TimeUnit是单位。
workQueue:任务队列,用于存放已提交但尚未执行的任务。
threadFactory:表示的是生产线程池工作线程里的线程工厂,一般使用默认的即可。
handler:拒绝策略,当前线程数已达到最大线程数,并且任务队列也已满的情况,就会按照策略拒绝此次请求。

4.32介绍一下缓存行和伪共享,以及如何解决伪共享?

缓存行:为了解决计算机系统中主内存与CPU之间运行速度差问题,会在CPU与主内存之间添加一级或多级高速缓冲存储器。这个缓存一般是被集成到CPU内部的,在缓存内部是按行存储的,其中每一行称为一个缓存行。一般是2的幂次方字节,64字节最常用。当CPU访问某个变量时,首先会去看CPU缓存中是否有该变量,如果有就直接从缓冲中获取,否则就去主存中获取,然后把该变量所在区域的一个缓存行大小的内存复制到缓存中。由于存放到缓存行的不是单个变量而是内存块,所以就有可能会把多个变量存放到同一个缓存行的。
伪共享:就是多核多线程并发场景下,多核要操作的不同变量处于同一缓存行,某cpu更新缓存行中数据,并将其写回缓存,同时其他处理器会使该缓存行失效,如需使用,还需从内存中重新加载。这对效率产生了较大的影响。
解决伪共享的问题:(缓存行填充)用空间换时间:以64字节的缓存行为例,伪共享问题产生的前提是,并发情况下,不同cpu对缓存行中不同变量的操作引起的。那么,如果把缓存行中仅存储目标变量,其余空间采用“无用”数据填充补齐64字节,就不会才产生伪共享问题。这种方式就是:缓存行填充(也称缓存行对齐)。
缓存一致性协议:就是一个处理器对缓存行里的某个数据或多个数据修改时,需要通知其他处理处理器,告诉它你的缓存行里的数据已经失效,赶紧去内存中读取最新值。MESI是因特尔的缓存一致性协议。MESI分别代表缓存行的四种不同状态:Modified(修改)、Exclusive(独占)、Shared(共享)、Invaild(失效)。缓存一致性协议是硬件级别的协议,软件无法控制它。
它的监听(嗅探)机制:
当缓存行处于Modified状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行写回内存,并标记为Shared状态
当缓存行处于Exclusive状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行标记为Shared状态
当缓存行处于Shared状态时,会时刻监听其他cpu对使缓存行失效的指令(即其他cpu的写入操作),一旦监听到,将本cpu的缓存行标记为Invalid状态(其他cpu进入Modified状态)
当缓存行处于Invalid状态时,从内存中读取,否则直接从缓存读取
总结:当某个cpu修改缓存行数据时,其他的cpu通过监听机制获悉共享缓存行的数据被修改,会使其共享缓存行失效。本cpu会将修改后的缓存行写回到主内存中。此时其他的cpu如果需要此缓存行共享数据,则从主内存中重新加载,并放入缓存,以此完成了缓存一致性。

4.33如何预防死锁

首先死锁发生的四个必要条件
(1)互斥条件 同一时间只能有一个线程抢占资源
(2)不可剥夺条件 一个线程已经占有的资源,在释放之前不会被其他线程抢占
(3)请求和保持条件 线程等待获取资源的过程中不会释放已占有的资源
(4)循环等待条件 多个线程互相等待对方释放资源
解决方法:
1. 尽量使用ReentrantLock类的tryLock()方法,可设置超时等待时间,如果在这段时间内线程还获取不了锁,就可以退出锁竞争以避免死锁的情况。
2. 尽量降低锁的使用粒度,不要几个功能共用同一把锁。
3. 尽量减少同步代码块的时候,尤其是在同步代码快中还调用了其他方法,这个方法也是同步方法或者方法内带有同步代码块。
4. 死锁检测,死锁检测是一个MySQL Server层的自动检测机制,可以及时发现两个或者多个session间互斥资源的申请造成的死锁,且会自动回滚一个(或多个)事物代价相对较小的session,让执行代价最大的先执行。该参数默认就是打开的,按理说也是必须要打开的,甚至在其他数据库中没有可以使其关闭的选项。

4.34为什么要进行多线程并发编程?

因为多核CPU时代的到来打破了单核CPU对多线程性能的限制。多核CPU意味着每个核心都能运行自己的线程,可以做到并行执行,减少了线程上下文切换的开销。并且随着对应用程序系统性能和吞吐量要求的提高,需要它能处理海量数据和请求,这些都会高并发编程有着迫切的需求。

4.35说说Java的线程安全问题

谈到线程安全,首先得说说什么是共享资源。共享资源的同一个进程中多个线程能够共享的,能够同时被这些线程都同时访问的资源。线程安全问题就是指当多个线程同时读写一个共享资源并且没有任何同步保护措施的时候,会导致出现脏数据和其他不可预见的结果的问题。

4.36说说LockSupport类

LockSupport类的主要方法就是两个静态方法,分别是park()和unpark()。它的作用其实与wait\notify有点类似,都是以线程为单位来阻塞线程和唤醒线程的。
区别:
(1)wait、notify和notifyAll必须配合Object Monitor一起使用,而park、unpark则不必;
(2)park、unpark是以线程为单位来阻塞和唤醒线程,而notify只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么精确。
(3)park、unpark可以先unpark,而wait/notify不能先notify。
【原理】:
每个线程都有自己的一个Parker对象,由counter、_condition和_mutex三部分组成。
(1)线程就像一个路人,Parker就像他旅行的背包,条件变量就好像他背包里的帐篷,_counter就好比他背包中的剩余干粮。
(2)调用park()就是要看需不需要停下来歇息,如果备用干粮耗尽(也就是_counter=0)的情况,那就需要到帐篷里休息(_condition)。
(3)调用unpark(),就好比让干粮充足(也就是让_counter=1),如果这时线程还在帐篷中,就唤醒他让他前进。如果这时线程还在运行的话,那么等到下次他调用park()方法,仅仅只会消耗干粮(_counter=0),不需停留继续前进。同时因为背包空间有限,多次调用unpark()仅会补充一份备用干粮,也就是说_counter的值最大为1。
image.png

4.37说说Unsafe类

image.png
Java是一门安全的语言,在大多数情况下都不会直接操作内存,而且java也不提倡直接操作内存。但是Java中提供了Unsafe类可以用来直接操作内存,Unsafe类使java语言拥有了像c语言的指针一样操作内存空间的能力,但同时具有操作内存空间的能力就意味着:
(1)不受JVM管理,也就意味着无法被GC,需要我们手动GC,稍有不慎就会造成内存泄漏;
(2)Unsafe类的不少方法中必须提供原始内存地址和被替换对象的地址,偏移量需要自己计算,一旦出现问题就是JVM崩溃级别的异常,导致整个JVM进程挂掉;
因此说这个类是不安全的,但是它也同时提供了各种各样非常强大的功能。比如CAS、线程调度、内存操作等,这些在Java的高并发以及网络编程中都使用得非常频繁。
【CAS】
(1)CAS操作就是Compare And Swap,它是JDK提供的非阻塞原子性操作,通过硬件保证了比较并更新操作的原子性。JDK中的Unsafe类提供了一系列的Compare And Swap*的方法,以boolean compareAndSwapLong(Object obj,long valueOffset,long expect,long update)为例。CAS中有四个操作数,分别是:对象内存位置、对象中变量的偏移量、变量预期值、新的值。它的操作含义就是,如果obj对象中内存偏移量为valueOffset的变量值为expect,则使用新的值update替换原来的expect值。这是处理器提供的一个原子指令。
【线程的恢复与运行】
(1)最经典的就是LockSupport类中的park与unpark方法,这两个方法都是直接调用的unsafe类中的park、unpark方法,让线程挂起与恢复。
【直接内存】
(1)直接内存并不是虚拟机运行时数据库的一部分,但是这部分内存也被频繁地使用,而且也可能导致oom异常。在jdk1.4新加入了nio类,引入了一种基于通道(Channel)和缓冲区(Buffer)的io方式,它可以使用native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样在一些场景下能显著提升性能,因为避免了java堆和native堆来回复制数据。
(2)本机直接内存不会受到java堆大小的限制,但是既然是内存,则肯定还是会受到本机总内存(包括物理内存、Swap分区或者分页文件)以及处理器寻址空间的限制。
(3)看操作系统