什么是并发?什么是并行?
解: 参考:深入理解Java并发编程(一):到底什么是线程安全-HollisChuang's Blog
什么是进程?什么是线程?
解:参考:深入理解Java并发编程(一):到底什么是线程安全-HollisChuang's Blog
类变量、成员变量和局部变量分别是什么?哪个是共享变量?
解: Java中共有三种变量,分别是类变量、成员变量和局部变量。他们分别存放在JVM的方法区、堆内存和栈内存中。
public class Variables {
/**
* 类变量
*/
private static int a;
/**
* 成员变量
*/
private int b;
/**
* 局部变量
* @param c
*/
public void test(int c){
int d;
}
}
上面定义的三个变量中,变量a就是类变量,变量b就是成员变量,而变量c和d是局部变量。
所以,变量a和b是共享变量,变量c和d是非共享变量。所以如果遇到多线程场景,对于变量a和b的操作是需要考虑线程安全的,而对于线程c和d的操作是不需要考虑线程安全的。
什么是线程安全?
解:线程安全是编程中的术语,指某个函数、函数库在并发环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。
简单来说,就是多个线程同时访问共享变量的时候,得到的结果和我们预期的一样,就是线程安全。这里所谓的预期,其实就是要满足所谓的原子性、有序性和可见性。这三个特性是并发编程的重点,后面还会涉及到。
并发编程中,最重要的三个特性是什么?
解:牢记并发编程三大特性:原子性、有序性和可见性。
并发编程中的原子性是什么?数据库的ACID中,A指的也是原子性,这两个原子性之间完全一样吗?
解:原子性是指:一个操作是不可中断的,要全部执行完成,要不就都不执行。
数据库事务中,保证原子性通过事务的提交和回滚,但是在并发编程中,是不涉及到回滚的。所以,并发编程中的原子性,强调的是一个操作的不可分割性
所以,在并发编程中,原子性的定义不应该和事务中的原子性完全一样。他应该定义为:一段代码,或者一个变量的操作,在没有执行完之前,不能被其他线程执行。
并发编程中的有序性是什么?如果不能保证有序性会发生什么问题?
解:有序性即程序执行的顺序按照代码的先后顺序执行。
Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。
比如 i+1 操作,可以被分解为:
load i;
add 1;
save i;
如果这三个指令的顺序被重排,那么结果可能就截然不同了。一旦不满足有序性,那么自行结果就和预期不同。
多线程场景中,有序性需要保证多个线程执行操作,和单线程顺序执行多次的结果一致。
什么情况下会影响程序执行的顺序?(为什么有序性会被破坏)
解: 现代编译器会对代码指令重排序,在重排的情况下,代码的执行顺序就有可能被改变。
什么是并发编程中的可见性?
解:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
并发编程中,如果无法保证可见性,会出现什么问题?
解:如果无法保证可见性,那么就是说多个线程之间的本地内存中对于同一个变量的值是不同的。
线程1从主存中取出a=1,保存到自己的工作内存。
线程1在自己的工作内存中操作a=a+1。
线程2从主存中取出a,这时a还是1 ,
以上情况,线程1对a的修改就不具有可见性,后果可想而知。
线程有几种状态,状态之间的流转是怎样的?
解:Java中线程的状态分为6种:
1.初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
2.运行(RUNNABLE):Java线程中将就绪(READY)和运行中(RUNNING)两种状态笼统的称为“运行”。
就绪(READY):线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中并分配cpu使用权 。
运行中(RUNNING):就绪(READY)的线程获得了cpu 时间片,开始执行程序代码。
3.阻塞(BLOCKED):表示线程阻塞于锁(关于锁,在后面章节会介绍)。
4.等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
5.超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
6. 终止(TERMINATED):表示该线程已经执行完毕。
状态流转如图:
Java虚拟机采用的线程调度模型是什么?
解:Java虚拟机采用抢占式调度模型。
抢占式调度模型 抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题。
系统会让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃CPU。
什么是线程优先级?线程优先级有什么用?
解:虽然Java线程调度是系统自动完成的,但是我们还是可以“建议”系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点——这项操作可以通过设置线程优先级来完成。Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
什么是守护线程?如何创建守护线程?
解: 在Java中有两类线程:User Thread(用户线程)、Daemon Thread(守护线程) 。用户线程一般用户执行用户级任务,而守护线程也就是“后台线程”,一般用来执行后台任务,守护线程最典型的应用就是GC(垃圾回收器)。
这两种线程其实是没有什么区别的,唯一的区别就是Java虚拟机在所有“用户线程”都结束后就会退出。
我们可以通过使用setDaemon()方法通过传递true作为参数,使线程成为一个守护线程。我们必须在启动线程之前调用一个线程的setDaemon()方法。否则,就会抛出一个java.lang.IllegalThreadStateException。
可以使用isDaemon()方法来检查线程是否是守护线程。
public class Main {
public static void main(String[] args) {
Thread t1 = new Thread();
System.out.println(t1.isDaemon());
t1.setDaemon(true);
System.out.println(t1.isDaemon());
t1.start();
t1.setDaemon(false);
}
}
以上代码输出结果:
false
true
Exception in thread "main" java.lang.IllegalThreadStateException
at java.lang.Thread.setDaemon(Thread.java:1359)
at com.hollis.Main.main(Main.java:16)
我们提到,当JVM中只剩下守护线程的时候,JVM就会退出,那么写一段代码测试下:
public class Main {
public static void main(String[] args) {
Thread childThread = new Thread(new Runnable() {
@Override public void run() {
while (true) {
System.out.println("I'm child thread..");
try {
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
childThread.start();
System.out.println("I'm main thread...");
}
}
以上代码中,我们在Main线程中开启了一个子线程,在并没有显示将其设置为守护线程的情况下,他是一个用户线程,代码比较好理解,就是子线程处于一个while(true)循环中,每隔一秒打印一次I'm child thread..
输出结果为:
I'm main thread...
I'm child thread..
I'm child thread.. .....
I'm child thread..
I'm child thread..
我们再把子线程设置成守护线程,重新运行以上代码。
public class Main {
public static void main(String[] args) {
Thread childThread = new Thread(new Runnable() {
@Override public void run() {
while (true) {
System.out.println("I'm child thread..");
try {
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
childThread.setDaemon(true);
childThread.start();
System.out.println("I'm main thread...");
}
}
以上代码,我们通过childThread.setDaemon(true);把子线程设置成守护线程,然后运行,得到以下结果:
I'm main thread...
I'm child thread..
子线程只打印了一次,也就是,在main线程执行结束后,由于子线程是一个守护线程,JVM就会直接退出了。
值得注意的是,在Daemon线程中产生的新线程也是Daemon的。
什么是ThreadLocal ,和线程有什么关系?
解: ThreadLocal是java.lang下面的一个类,是用来解决java多线程程序中并发问题的一种途径;通过为每一个线程创建一份共享变量的副本来保证各个线程之间的变量的访问和修改互相不影响;
ThreadLocal存放的值是线程内共享的,线程间互斥的,主要用于线程内共享一些数据,避免通过参数来传递,这样处理后,能够优雅的解决一些实际问题。
比如一次用户的页面操作请求,我们可以在最开始的filter中,把用户的信息保存在ThreadLocal中,在同一次请求中,在使用到用户信息,就可以直接到ThreadLocal中获取就可以了。
还有一个典型的应用就是保存数据库连接,我们可以在第一次初始化Connection的时候,把他保存在ThreadLocal中。
ThreadLocal有四个方法,分别为:
initialValue 返回此线程局部变量的初始值
get
返回此线程局部变量的当前线程副本中的值。如果这是线程第一次调用该方法,则创建并初始化此副本。
set
将此线程局部变量的当前线程副本中的值设置为指定值。许多应用程序不需要这项功能,它们只依赖于 initialValue() 方法来设置线程局部变量的值。
remove
移除此线程局部变量的值。
什么是线程池?
解:线程池是池化技术的一种典型实现,所谓池化技术就是提前保存大量的资源,以备不时之需。在机器资源有限的情况下,使用池化技术可以大大的提高资源的利用率,提升性能等。
在编程领域,比较典型的池化技术有:
线程池、连接池、内存池、对象池等。
线程池,说的就是提前创建好一批线程,然后保存在线程池中,当有任务需要执行的时候,从线程池中选一个线程来执行任务。
Java中如何使用线程池?
解:
作者推荐使用guava提供的ThreadFactoryBuilder来创建线程池。
public class ExecutorsDemo {
private static ThreadFactory namedThreadFactory = new ThreadFactoryBuilder() .setNameFormat("demo-pool-%d").build();
private static ExecutorService pool = new ThreadPoolExecutor(5, 200, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());
public static void main(String[] args) {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
pool.execute(new SubThread());
}
}
}
为什么不建议使用Executors创建线程池?
解: Executors底层是通过LinkedBlockingQueue实现的。
LinkedBlockingQueue是一个用链表实现的有界阻塞队列,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。
不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。也就是说,如果我们不设置LinkedBlockingQueue的容量的话,其默认容量将会是Integer.MAX_VALUE。
而Executors创建线程池时,并未指定容量。此时,LinkedBlockingQueue就是一个无边界队列,对于一个无边界队列来说,是可以不断的向队列中加入任务的,这种情况下就有可能因为任务过多而导致内存溢出问题。
Executors类可以创建多少种线程池,各种什么特点?
解: Executors的创建线程池的方法,创建出来的线程池都实现了ExecutorService接口。常用方法有以下几个: newFiexedThreadPool(int Threads):创建固定数目线程的线程池。
newCachedThreadPool():创建一个可缓存的线程池,调用execute 将重用以前构造的线程(如果线程可用)。如果没有可用的线程,则创建一个新线程并添加到池中。终止并从缓存中移除那些已有 60 秒钟未被使用的线程。
newSingleThreadExecutor()创建一个单线程化的Executor。
newScheduledThreadPool(int corePoolSize)创建一个支持定时及周期性的任务执行的线程池,多数情况下可用来替代Timer类。
Java中如何创建线程?
解:在Java中,共有四种方式可以创建线程,分别是继承Thread类创建线程、实现Runnable接口创建线程、通过Callable和FutureTask创建线程以及通过线程池创建线程。
https://blog.csdn.net/w372426096/article/details/85234262
什么是缓存一致性?
解:在多核CPU,多线程的场景中,每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。 在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。
缓存一致性和内存一致性有什么联系和区别?
解: 缓存一致性(Cache Coherence),解决是多个缓存副本之间的数据的一致性问题。 内存一致性(Memory Consistency),保证的是多线程程序访问内存时可以读到什么值。 内存一致性就是程序员(编程语言)、编译器、CPU间的一种协议。这个协议保证了程序访问内存时会得到什么值。如果没有Memory Consistency,程序员写的程序代码的输出结果是不确定的。 简单点说,内存一致性,就是保证并发场景下的程序运行结果和程序员预期是一样的(当然,要通过加锁等方式),包括的就是并发编程中的原子性、有序性和可见性。而缓存一致性说的可以简单的类比程并发编程中的可见性。
缓存一致性就是Java并发编程中的可见性吗?
解:并不是。 缓存一致性指的是多个CPU的多级缓存中数据不一致的现象。 Java并发编程中的可见性指的是多个线程的本地内存中数据互相不可见的现象。 二者可以类比,但是并不完全一样。
如何解决缓存一致性问题?
解:首先,缓存一致性是由于引入缓存而导致的问题,所以,这是很多CPU厂商必须解决的问题。为了解决前面提到的缓存数据不一致的问题,人们提出过很多方案,通常来说有以下2种方案:
1、通过在总线加LOCK#锁的方式。
2、通过缓存一致性协议(Cache Coherence Protocol)。
在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从其内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。
但是由于在锁住总线期间,其他CPU无法访问内存,会导致效率低下。因此出现了第二种解决方案,通过缓存一致性协议来解决缓存一致性问题。
什么是缓存一致性协议?
解:缓存一致性协议(Cache Coherence Protocol),最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。
MESI的核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
在MESI协议中,每个缓存可能有有4个状态,它们分别是:
M(Modified):这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
E(Exclusive):这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中。
S(Shared):这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中。
I(Invalid):这行数据无效。
关于MESI的更多细节这里就不详细介绍了,读者只要知道,MESI是一种比较常用的缓存一致性协议,他可以用来解决缓存之间的数据一致性问题就可以了。
但是,值得注意的是,传统的MESI协议中有两个行为的执行成本比较大。
一个是将某个Cache Line标记为Invalid状态,另一个是当某Cache Line当前状态为Invalid时写入新的数据。所以CPU通过Store Buffer和Invalidate Queue组件来降低这类操作的延时。
如图:
当一个CPU进行写入时,首先会给其它CPU发送Invalid消息,然后把当前写入的数据写入到Store Buffer中。然后异步在某个时刻真正的写入到Cache中。
当前CPU核如果要读Cache中的数据,需要先扫描Store Buffer之后再读取Cache。
但是此时其它CPU核是看不到当前核的Store Buffer中的数据的,要等到Store Buffer中的数据被刷到了Cache之后才会触发失效操作。
而当一个CPU核收到Invalid消息时,会把消息写入自身的Invalidate Queue中,随后异步将其设为Invalid状态。
和Store Buffer不同的是,当前CPU核心使用Cache时并不扫描Invalidate Queue部分,所以可能会有极短时间的脏读问题。
所以,为了解决缓存的一致性问题,比较典型的方案是MESI缓存一致性协议。
什么是CPU时间片?
解:很多人都知道,现在我们用到操作系统,无论是Windows、Linux还是MacOS等其实都是多用户多任务分时操作系统。使用这些操作系统的“用户”是可以“同时”干多件事的,这已经是日常习惯了,并没觉得有什么特别。
但是实际上,对于单CPU的计算机来说,在CPU中,同一时间是只能干一件事儿的。
为了看起来像是“同时干多件事”,分时操作系统是把CPU的时间划分成长短基本相同的时间区间,即”时间片”,通过操作系统的管理,把这些时间片依次轮流地分配给各个“用户”使用。
如果某个“用户”在时间片结束之前,整个任务还没有完成,“用户”就必须进入到就绪状态,放弃CPU,等待下一轮循环。此时CPU又分配给另一个“用户”去使用。
不同的操作系统,在选择“用户”分配时间片的调度算法是不一样的,常用的有FCFS、轮转、SPN、SRT、HRRN、反馈等,由于不是本文重点,就不展开了。
CPU时间片会导致什么问题?
解:前面我们提到过,线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。所以在多线程场景下,就会发生原子性问题。因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作。
就好像我们去电话亭打电话,一共有三个步骤,查找电话,拨号,交流。由于我们在电话亭中可以停留的时间有限,有可能刚刚找到电话号码,时间到了,就被赶出来了。
在单线程中,一个读改写就算不是原子操作也没关系,因为只要这个线程再次被调度,这个操作总是可以执行完的。但是在多线程场景中可能就有问题了。因为多个线程可能会对同一个共享资源进行操作。
比如经典的 i++ 操作,对于一个简单的i++操作,一共有三个步骤:load , add ,save 。共享变量就会被多个线程同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致,举个例子:如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2。
什么是指令重排?
解:在计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。
但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。这种优化就是指令重排。
除了CPU会对指令进行重排以外,JVM也会有类似的指令重排优化。
指令重排会导致什么问题?
解:在某些情况下,指令重排会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。
由于指令重排,CPU就会对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是可能产生有序性问题。
如图,在多线程的场景中,代码的执行顺序被重排,就会发生意想不到的结果。
什么是计算机内存模型?解决了什么问题?
解:之前几期我们提到的,多CPU多级缓存导致的一致性问题、CPU时间片机制导致的原子性问题、以及处理器优化和指令重排导致的有序性问题等,都硬件的不断升级导致的。那么,有没有什么机制可以很好的解决上面的这些问题呢?
最简单直接的做法就是废除处理器和处理器的优化技术、废除CPU缓存,让CPU直接和主存交互。但是,这么做虽然可以保证多线程下的并发问题。但是,这就有点因噎废食了。
所以,为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型。
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
什么是Java内存模型?
解:前面几期我们介绍过了计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。
我们知道,Java程序是需要运行在Java虚拟机上面的,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
提到Java内存模型,一般指的是JDK 5 开始使用的新的内存模型,主要由JSR-133: JavaTM Memory Model and Thread Specification 描述。感兴趣的可以参看下这份PDF文档(http://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf)
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。
JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
在Java中提供哪些并发处理相关的关键字?
解:主要有 volatile、synchronized、final、concurren 包等。
JVM中哪些数据区域是线程共享的?
解:Java堆和方法区。
在JVM中,堆内存是所有线程共享的。堆中只包含对象,没有其他东西。所以,堆上也无法保存基本类型和对象引用。堆和栈分工明确。但是,对象的引用其实也是对象的一部分。这里值得一提的是,数组是保存在堆上面的,即使是基本类型的数据,也是保存在堆中的。因为在Java中,数组是对象。
除了堆,还有一部分数据可能保存在JVM中的方法区中,比如类的静态变量。方法区和栈类似,其中只包含基本类型和对象应用。和栈不同的是,方法区中的静态变量可以被所有线程访问到。
什么是对象锁?什么是类锁?
解: 堆,上面存放着所有对象
方法区,上面存放着静态变量
那么,如果有多个线程想要同时访问同一个对象或者静态变量,就需要被管控,否则可能出现不可预期的结果。
为了协调多个线程之间的共享数据访问,虚拟机给每个对象和类都分配了一个锁。这个锁就像一个特权,在同一时刻,只有一个线程可以“拥有”这个类或者对象。如果一个线程想要获得某个类或者对象的锁,需要询问虚拟机。当一个线程向虚拟机申请某个类或者对象的锁之后,也许很快或者也许很慢虚拟机可以把锁分配给这个线程,同时这个线程也许永远也无法获得锁。当线程不再需要锁的时候,他再把锁还给虚拟机。这时虚拟机就可以再把锁分配给其他申请锁的线程。
类锁其实通过对象锁实现的。因为当虚拟机加载一个类的时候,会会为这个类实例化一个 java.lang.Class 对象,当你锁住一个类的时候,其实锁住的是其对应的Class 对象。
synchronized 如何使用?
解:
synchronized是Java提供的一个并发控制的关键字。主要有两种用法,分别是同步方法和同步代码块。也就是说,synchronized既可以修饰方法也可以修饰代码块。
public class SynchronizedDemo {
//同步方法
public synchronized void doSth(){
System.out.println(“Hello World”);
}
//同步代码块
public void doSth1(){
synchronized (SynchronizedDemo.class){
System.out.println("Hello World");
}
}
}
如何使用synchronized实现对象锁和类锁?
解:当一个对象中有synchronized method或synchronized block的时候调用此对象的同步方法或进入其同步区域时,就必须先获得对象锁。如果此对象的对象锁已被其他调用者占用,则需要等待此锁被释放。(方法锁也是对象锁) java的所有对象都含有1个互斥锁,这个锁由JVM自动获取和释放。线程进入synchronized方法的时候获取该对象的锁,当然如果已经有线程获取了这个对象的锁,那么当前线程会等待;synchronized方法正常返回或者抛异常而终止,JVM会自动释放对象锁。这里也体现了用synchronized来加锁的1个好处,方法抛异常的时候,锁仍然可以由JVM来自动释放。
对象锁的两种形式:
public class Test {
// 对象锁:形式1(方法锁)
public synchronized void Method1() {
System.out.println(“我是对象锁也是方法锁”);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 对象锁:形式2(代码块形式)
public void Method2() {
synchronized (this) {
System.out.println("我是对象锁");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
如何使用synchronized实现类锁?
解:由于一个class不论被实例化多少次,其中的静态方法和静态变量在内存中都只有一份。所以,一旦一个静态的方法被申明为synchronized。此类所有的实例化对象在调用此方法,共用同一把锁,我们称之为类锁。
对象锁是用来控制实例方法之间的同步,类锁是用来控制静态方法(或静态变量互斥体)之间的同步。
类锁只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定实例方法和静态方法的区别的。
java类可能会有很多个对象,但是只有1个Class对象,也就是说类的不同实例之间共享该类的Class对象。Class对象其实也仅仅是1个java对象,只不过有点特殊而已。由于每个java对象都有1个互斥锁,而类的静态方法是需要Class对象。所以所谓的类锁,不过是Class对象的锁而已。获取类的Class对象有好几种,最简单的就是[类名.class]的方式。
public class Test {
// 类锁:形式1
public static synchronized void Method1() {
System.out.println("我是类锁一号");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 类锁:形式2
public void Method2() {
synchronized (Test.class) {
System.out.println("我是类锁二号");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
synchronized同步方法是如何实现的?
解:先看一个synchronized同步方法:
public synchronized void doSth(){
System.out.println("Hello World");
}
反编译后内容:
public synchronized void doSth();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello World
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
方法级的同步是隐式的。 同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。 这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
synchronized同步代码块是如何实现的?
解:同步代码块使用monitorenter和monitorexit两个指令实现。 可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。
synchronized是如何实现原子性的?
解:再回顾下原子性: “原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。”
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。第250期我们介绍过,这两个字节码指令,在Java中对应的关键字就是synchronized。
通过monitorenter和monitorexit指令,可以保证被synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。
线程1在执行monitorenter指令的时候,会对Monitor进行加锁,加锁后其他线程无法获得锁,除非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是,他并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性。
synchronized是如何实现可见性的?
解:再回顾下可见性:
“可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。”
前面我们介绍过,被synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁。而为了保证可见性,有一条规则是这样的:对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的值。
所以,synchronized关键字锁住的对象,其值是具有可见性的。
synchronized是如何实现有序性的?
解:再回顾下有序性:
” 有序性即程序执行的顺序按照代码的先后顺序执行。
除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是可能存在有序性问题。”
这里需要注意的是,synchronized是无法禁止指令重排和处理器优化的。也就是说,synchronized无法避免上述提到的问题。
那么,为什么还说synchronized也提供了有序性保证呢?
这就要再把有序性的概念扩展一下了。Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。
以上这句话也是《深入理解Java虚拟机》中的原句,但是怎么理解呢?周志明并没有详细的解释。这里我简单扩展一下,这其实和as-if-serial语义有关。
as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。
这里不对as-if-serial语义详细展开了,简单说就是,as-if-serial语义保证了单线程中,指令重排是有一定的限制的,而只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的。当然,实际上还是有重排的,只不过我们无须关心这种重排的干扰。
所以呢,由于synchronized修饰的代码,同一时间只能被同一线程访问。那么也就是单线程执行的。所以,可以保证其有序性。
什么是Monitor?
解:参考资料:深入理解多线程(四)—— Moniter的实现原理-HollisChuang's Blog
什么是重量级锁?
解:synchronized其实是借助Monitor实现的,在加锁时会调用objectMonitor的enter方法,解锁的时候会调用exit方法。事实上,只有在JDK1.6之前,synchronized的实现才会直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。
jdk1.6对synchronized做了哪些锁优化?
解:synchronized 是重量级锁,所以在JDK1.6中对锁进行了很多的优化,进而出现轻量级锁,偏向锁,锁消除,适应性自旋锁,锁粗化(自旋锁在1.4就有,只不过默认的是关闭的,jdk1.6是默认开启的),这些操作都是为了在线程之间更高效的共享数据 ,解决竞争问题。
锁的优缺点对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁,竞争锁的线程使用自旋会消耗 CPU | 追求响应时间,同步代码块执行速度非常快 |
线程竞争不需要自旋,不会消耗 CPU | 线程阻塞,响应时间慢 | 追求吞吐量,同步代码块执行速度较长 |
什么是锁消除?
解:除了自旋锁之后,JDK中还有一种锁的优化被称之为锁消除。还拿去银行取钱的例子说。
你去银行取钱,所有情况下都需要取号,并且等待吗?其实是不用的,当银行办理业务的人不多的时候,可能根本不需要取号,直接走到柜台前面办理业务就好了。
能这么做的前提是,没有人和你抢着办业务。
上面的这种例子,在锁优化中被称作“锁消除”,是JIT编译器对内部锁的具体实现所做的一种优化。
在动态编译同步块的时候,JIT编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。
如以下代码: public void f() { Object hollis = new Object(); synchronized(hollis) { System.out.println(hollis); } } 代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。优化成:
public void f() { Object hollis = new Object(); System.out.println(hollis); } 这里,可能有读者会质疑了,代码是程序员自己写的,程序员难道没有能力判断要不要加锁吗?就像以上代码,完全没必要加锁,有经验的开发者一眼就能看的出来的。其实道理是这样,但是还是有可能有疏忽,比如我们经常在代码中使用StringBuffer作为局部变量,而StringBuffer中的append是线程安全的,有synchronized修饰的,这种情况开发者可能会忽略。这时候,JIT就可以帮忙优化,进行锁消除。
了解我的朋友都知道,一般到这个时候,我就会开始反编译,然后拿出反编译之后的代码来证明锁优化确实存在。
但是,之前很多例子之所以可以用反编译工具,是因为那些“优化”,如语法糖等,是在javac编译阶段发生的,并不是在JIT编译阶段发生的。而锁优化,是JIT编译器的功能,所以,无法使用现有的反编译工具查看具体的优化结果。(关于javac编译和JIT编译的关系和区别,我在我的知识星球中单独发了一篇文章介绍。) 但是,如果读者感兴趣,还是可以看的,只是会复杂一点,首先你要自己build一个fasttest版本的jdk,然后在使用java命令对.class文件进行执行的时候加上-XX:+PrintEliminateLocks参数。而且jdk的模式还必须是server模式。
总之,读者只需要知道,在使用synchronized的时候,如果JIT经过逃逸分析之后发现并无线程安全问题的话,就会做锁消除。
什么是锁粗化?
解:很多人都知道,在代码中,需要加锁的时候,我们提倡尽量减小锁的粒度,这样可以避免不必要的阻塞。
这也是很多人原因是用同步代码块来代替同步方法的原因,因为往往他的粒度会更小一些,这其实是很有道理的。
还是我们去银行柜台办业务,最高效的方式是你坐在柜台前面的时候,只办和银行相关的事情。如果这个时候,你拿出手机,接打几个电话,问朋友要往哪个账户里面打钱,这就很浪费时间了。最好的做法肯定是提前准备好相关资料,在办理业务时直接办理就好了。
加锁也一样,把无关的准备工作放到锁外面,锁内部只处理和并发相关的内容。这样有助于提高效率。
那么,这和锁粗化有什么关系呢?可以说,大部分情况下,减小锁的粒度是很正确的做法,只有一种特殊的情况下,会发生一种叫做锁粗化的优化。
就像你去银行办业务,你为了减少每次办理业务的时间,你把要办的五个业务分成五次去办理,这反而适得其反了。因为这平白的增加了很多你重新取号、排队、被唤醒的时间。
如果在一段代码中连续的对同一个对象反复加锁解锁,其实是相对耗费资源的,这种情况可以适当放宽加锁的范围,减少性能消耗。
当JIT发现一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中的时候,会将加锁同步的范围扩散(粗化)到整个操作序列的外部。
如以下代码:
for(int i=0;i<100000;i++){
synchronized(this){
do();
}
会被粗化成:
synchronized(this){
for(int i=0;i<100000;i++){
do();
}
这其实和我们要求的减小锁粒度并不冲突。减小锁粒度强调的是不要在银行柜台前做准备工作以及和办理业务无关的事情。而锁粗化建议的是,同一个人,要办理多个业务的时候,可以在同一个窗口一次性办完,而不是多次取号多次办理。
什么是volatile?
解:volatile这个关键字,不仅仅在Java语言中有,在很多语言中都有的,而且其用法和语义也都是不尽相同的。尤其在C语言、C++以及Java中,都有volatile关键字。都可以用来声明变量或者对象。
volatile通常被比喻成”轻量级的synchronized“,也是Java并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。
volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了。
public class Singleton {
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
如以上代码,是一个比较典型的使用双重锁校验的形式实现单例的,其中使用volatile关键字修饰可能被多个线程同时访问到的singleton。
volatile在原子性、有序性和可见性中都可以满足吗?
解:volatile是不能保证原子性的
volatile是如何解决可见性问题的?
解:对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。
所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。
volatile是如何解决有序性问题的?
解:volatile除了可以保证数据的可见性之外,还有一个强大的功能,那就是他可以禁止指令重排优化等。
普通的变量仅仅会保证在该方法的执行过程中所依赖的赋值结果的地方都能获得正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。
volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行。这就保证了有序性。被volatile修饰的变量的操作,会严格按照代码顺序执行,load->add->save 的执行顺序就是:load、add、save。
volatile可以禁止指令重排的底层原理是什么?
解:volatile是通过内存屏障来来禁止指令重排的。
什么是内存屏障?
解:内存屏障(Memory Barrier)是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
下表描述了和volatile有关的指令重排禁止行为:
从上表我们可以看出: 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
具体实现方式是在编译期生成字节码时,会在指令序列中增加内存屏障来保证,下面是基于保守策略的JMM内存屏障插入策略: 在每个volatile写操作的前面插入一个StoreStore屏障。
对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。 在每个volatile写操作的后面插入一个StoreLoad屏障。 对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
在每个volatile读操作的后面插入一个LoadLoad屏障。 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
在每个volatile读操作的后面插入一个LoadStore屏障。 对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
所以,volatile通过在volatile变量的操作前后插入内存屏障的方式,来禁止指令重排,进而保证多线程情况下对共享变量的有序性。
volatile为什么不能解决原子性问题?
解:synchronized可以保证原子性 ,因为被synchronized修饰的代码片段,在进入之前加了锁,只要他没执行完,其他线程是无法获得锁执行这段代码片段的,就可以保证他内部的代码可以全部被执行。进而保证原子性。
但是synchronized对原子性保证也不绝对,如果真要较真的话,一旦代码运行异常,也没办法回滚。所以呢,在并发编程中,原子性的定义不应该和事务中的原子性一样。他应该定义为:一段代码,或者一个变量的操作,在没有执行完之前,不能被其他线程执行。
那么,为什么volatile不能保证原子性呢?因为他不是锁,他没做任何可以保证原子性的处理。当然就不能保证原子性了。
有了synchronized,为什么还需要voaltile?
解:
首先,synsynchronized其实是一种加锁机制,那么既然是锁,天然就具备以下几个缺点:
1、有性能损耗:虽然在JDK 1.6中对synchronized做了很多优化,如如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。但是他毕竟还是一种锁。所以,无论是使用同步方法还是同步代码块,在同步操作之前还是要进行加锁,同步操作之后需要进行解锁,这个加锁、解锁的过程是要有性能损耗的。
2、产生阻塞:无论是同步方法还是同步代码块,无论是ACC_SYNCHRONIZED还是monitorenter、monitorexit都是基于Monitor实现的。基于Monitor对象,当多个线程同时访问一段同步代码时,首先会进入Entry Set,当有一个线程获取到对象的锁之后,才能进行The Owner区域,其他线程还会继续在Entry Set等待。并且当某个线程调用了wait方法后,会释放锁并进入Wait Set等待。所以,synchronize实现的锁本质上是一种阻塞锁。
除了前面我们提到的volatile比synchronized性能好以外,volatile其实还有一个很好的附加功能,那就是禁止指令重排。因为volatile借助了内存屏障来帮助其解决可见性和有序性问题,而内存屏障的使用还为其带来了一个禁止指令重排的附件功能,所以在有些场景中是可以避免发生指令重排的问题的。
详细参考:https://www.hollischuang.com/archives/3928
Java线程的sleep 和 wait的区别是什么?
解:首先,sleep是Thread类的方法,wait是Object类中定义的方法。都可以让线程暂时让出CPU, sleep()方法使执行中的线程主动让出CPU,进入阻塞状态(block),不会释放对象锁,在sleep指定时间后CPU便会到可执行状态(runnable)。注意,runnable并不表示线程一定可以获得CPU。需要等待被CPU调度后进入运行中。
wait()方法使执行中的线程主动让出CPU,会放弃对象锁,进入等待队列(waitting queue)中,只有调用了notify()/notifyAll()方法,才会从等待队列中被移出,重新获取锁之后,便可再次变成可执行状态(runnable)。
sleep()方法可以在任何地方使用;wait()方法则只能在同步方法或同步块中使用;
Java线程的notify 和 notifyAll的区别是什么?
解:当一个线程进入wait之后,就必须等其他线程notify()/notifyAll(),才会从等待队列中被移出。
使用notifyAll,可以唤醒所有处于wait状态的线程,使其重新进入锁的争夺队列中,而notify只能唤醒一个。
但是唤醒的这些线程只是进入争夺队列,并不表示表示立即就可以获得CPU开始执行,因为wait方法被调用的时候,线程已经释放了对象锁。
所以,被notify()/notifyAll()唤醒的线程,只是表示他们可以竞争锁了,竞争到锁之后才有机会被CPU调度。
那么notifyAll虽然可以把所有线程都唤醒,让他们都可以竞争锁,但是最终也只有一个可以获得锁并执行。
什么是死锁?
解:死锁是指两个或两个以上的进程(或线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
比如:丈母娘要求先买房才能结婚,但是女婿说先结婚买房
死锁的四个必要条件?
解:产生死锁的四个必要条件:
(1) 互斥条件:一个资源每次只能被一个进程使用。
(2) 占有且等待:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
(3)不可强行占有:进程已获得的资源,在末使用完之前,不能强行剥夺。
(4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
如何解除与预防死锁?
解:前面介绍过死锁的四个必要条件,想好解除和预防死锁,就避免4个条件同时发生就行了。
一般从以下几个方面入手
破坏不可抢占:设置优先级,使优先级高的可以抢占资源
破坏循环等待:保证多个进程(线程)的执行顺序相同即可避免循环等待。
如执行顺序都是:A->B->C,那就可以避免循环等待。
最常用的避免方法就是破坏循环等待,就是当我们有多个事务的时候,最好让这几个事务的执行顺序相同。
如事务1:A->B->C ,事务2:C->D->A,这种情况就有可能导致死锁。
即事务1占有了A,等待C,而事务2占有了C在等待A。
所以,要避免死锁就把事务2改为:A -> D-> C。
为什么wait, notify 和 notifyAll这些方法不在thread类里面?
解:因为Java锁的目标是对象,所以wait、notify和notifyAll针对的目标都是对象,所以把他们定义在Object类中。
什么是ThreadLoacal?
解:ThreadLocal是Java里一种特殊的变量。每个线程都有一个ThreadLocal就是每个线程都拥有了自己独立的一个变量,每一个线程都可以对这个都有的变量进行set和get,而不会影响到其他线程的ThreadLocal变量,竞争条件被彻底消除了。
ThreadLoacal底层的数据结构是什么?
解:ThreadLocal中用于保存线程的独有变量的数据结构是一个内部类:ThreadLocalMap,也是一k-v结构。
key就是当前的ThreadLoacal对象,而v就是我们想要保存的值。
线程的start()方法和run()方法的区别?
解:通常,我们实现线程有多种种方法,其中比较常见的两种:
1、继承Thread类,重写run方法。
2、实现Runnable接口,实现run方法
可见,run方法是线程真正执行的方法。
而我们创建好线程之后,想要启动这个线程,则需要调用其start方法。所以,start方法是启动一个线程的入口。
如果在创建好线程之后,直接调用其run方法,那么就会在单线程中直接运行run方法,不会起到多线程的效果。
Runnable接口和Callable接口的区别?
解:Runnable接口和Callable接口都可以用来创建新线程,实现Runnable的时候,需要实现run方法;实现Callable接口的话,需要实现call方法。
Runnable的run方法无返回值,Callable的call方法有返回值,类型为Object
Callable中可以够抛出checked exception,而Runnable不可以。
Callable和Runnable都可以应用于executors。而Thread类只支持Runnable。
Java中的同步集合与并发集合有什么区别?
解:同步集合可以简单地理解为通过synchronized来实现同步的集合。如果有多个线程调用同步集合的方法,它们将会串行执行。 在Java中,同步集合主要包括2类:
1、Vector、Stack、HashTable
2、Collections类中提供的静态工厂方法创建的类
并发集合 是jdk1.5新增特性,增加了并发包java.util.concurrent.*。
同步容器(如Vector)的所有操作一定是线程安全的吗?
解:同步容器直接保证单个操作的线程安全性,但是无法保证复合操作的线程安全,遇到这种情况时,必须要通过主动加锁的方式来实现。
简单举一个例子,我们定义如下删除Vector中最后一个元素方法:
public Object deleteLast(Vector v){
int lastIndex = v.size()-1;
v.remove(lastIndex);
}
}
上面这个方法是一个复合方法,包括size()和remove(),乍一看上去好像并没有什么问题,无论是size()方法还是remove()方法都是线程安全的,那么整个deleteLast方法应该也是线程安全的。
但是时,如果多线程调用该方法的过程中,remove方法有可能抛出ArrayIndexOutOfBoundsException。
Exception in thread "Thread-1" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 879
at java.util.Vector.remove(Vector.java:834)
at com.hollis.Test.deleteLast(EncodeTest.java:40)
at com.hollis.Test$2.run(EncodeTest.java:28)
at java.lang.Thread.run(Thread.java:748)
详情参考:面试官问我同步容器(如Vector)的所有操作一定是线程安全的吗?我懵了!-HollisChuang...
什么是CopyOnWrite?
解:并发包中的CopyOnWriteArrayList和CopyOnWriteArraySet是Copy-On-Write的两种实现。
Copy-On-Write容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。 CopyOnWriteArrayList中add/remove等写方法是需要加锁的,而读方法是没有加锁的。 这样做的好处是我们可以对CopyOnWrite容器进行并发的读,当然,这里读到的数据可能不是最新的。因为写时复制的思想是通过延时更新的策略来实现数据的最终一致性的,并非强一致性。
什么是AQS?
解:AbstractQueuedSynchronizer (抽象队列同步器,以下简称 AQS)出现在 JDK 1.5 中。AQS 是很多同步器的基础框架,比如 ReentrantLock、CountDownLatch 和 Semaphore 等都是基于 AQS 实现的。除此之外,我们还可以基于 AQS,定制出我们所需要的同步器。
AQS 的使用方式通常都是通过内部类继承 AQS 实现同步功能,通过继承 AQS,可以简化同步器的实现。
原理参考:AbstractQueuedSynchronizer 原理分析 - 独占/共享模式 | 田小波的技术...
什么是ReentrantLock?
解:Java语言直接提供了synchronized关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。 java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加锁,ReentrantLock 内部是基于 AbstractQueuedSynchronizer(以下简称AQS)实现的。
ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。
用法:
ReentrantLock和synchronized的区别?
解:ReentrantLock 和 synchronized 都是用于线程的同步控制,但它们在功能上来说差别还是很大的。对比下来 ReentrantLock 功能明显要丰富的多。
二者相同点是,都是可重入的。
二者也有很多不同,如:
synchronized是Java内置特性,而ReentrantLock是通过Java代码实现的。 synchronized是可以自动获取/释放锁的,但是ReentrantLock需要手动获取/释放锁。
ReentrantLock还具有响应中断、超时等等待等特性。
ReentrantLock可以实现公平锁,而synchronized只是非公平锁。
什么是CAS?
解:CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”这其实和乐观锁的冲突检查+数据更新的原理是一样的。
在JDK1.5 中新增java.util.concurrent(J.U.C)就是建立在CAS之上的。相对于对于synchronized这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以J.U.C在性能上有了很大的提升。
我们以java.util.concurrent中的AtomicInteger为例,看一下在不使用锁的情况下是如何保证线程安全的。主要理解getAndIncrement方法,该方法的作用相当于 ++i 操作。
getAndIncrement采用了CAS操作,每次从内存中读取数据然后将此数据和+1后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。而compareAndSet利用JNI来完成CPU指令的操作。
什么是CAS的ABA问题?
解:CAS会导致“ABA问题”。
CAS算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。
比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。
如何解决CAS的ABA问题?
解:部分乐观锁的实现是通过版本号(version)的方式来解决ABA问题,乐观锁每次在执行数据的修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现ABA问题,因为版本号只会增加不会减少。
什么是Java中的Unsafe类?
解:Unsafe是CAS的核心类。因为Java无法直接访问底层操作系统,而是通过本地(native)方法来访问。不过尽管如此,JVM还是开了一个后门,JDK中有一个类Unsafe,它提供了硬件级别的原子操作。
Unsafe是Java中一个底层类,包含了很多基础的操作,比如数组操作、对象操作、内存操作、CAS操作、线程(park)操作、栅栏(Fence)操作,JUC包、一些三方框架都使用Unsafe类来保证并发安全。
Unsafe类在jdk 源码的多个类中用到,这个类的提供了一些绕开JVM的更底层功能,基于它的实现可以提高效率。但是,它是一把双刃剑:正如它的名字所预示的那样,它是Unsafe的,它所分配的内存需要手动free(不被GC回收)。
Unsafe类,提供了JNI某些功能的简单替代:确保高效性的同时,使事情变得更简单。 Unsafe类提供了硬件级别的原子操作,主要提供了以下功能:
1、通过Unsafe类可以分配内存,可以释放内存;
2、可以定位对象某字段的内存位置,也可以修改对象的字段值,即使它是私有的;
3、将线程进行挂起与恢复
4、CAS操作
ReentrantLock 是如何实现可重入性的?
解:ReentrantLock 内部自定义了同步器 Sync,其实就是加锁的时候通过 CAS 算法,将线程对象放到一个双向链表中,每次获取锁的时候,看下当前维护的那个线程 ID 和当前请求的线程 ID 是否一样,一样就可重入了。
Atomic包的作用是什么?
解:Java从JDK1.5开始提供了java.util.concurrent.atomic包,方便程序员在多线程环境下,无锁的进行原子操作。Atomic包里的类基本都是使用Unsafe实现的包装类,核心操作是CAS原子操作;
在Atomic包里一共有12个类,四种原子更新方式,分别是原子更新基本类型,原子更新数组,原子更新引用和原子更新字段。Atomic包里的类基本都是使用Unsafe实现的包装类。
如何以原子方式更新基本类型?
解:java.util.concurrent.atomic包中以下三个类是以原子方式更新基本类型:
AtomicBoolean:原子更新布尔类型。
AtomicInteger:原子更新整型。
AtomicLong:原子更新长整型。
以AtomicInteger为例,其常用方法如下: int addAndGet(int delta) :以原子方式将输入的数值与实例中的值(AtomicInteger里的value)相加,并返回结果 boolean compareAndSet(int expect, int update) :如果输入的数值等于预期值,则以原子方式将该值设置为输入的值。 int getAndIncrement():以原子方式将当前值加1,注意:这里返回的是自增前的值。
void lazySet(int newValue):最终会设置成newValue,使用lazySet设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值。 int getAndSet(int newValue):以原子方式设置为newValue的值,并返回旧值。
如何以原子方式更新数组?
解:java.util.concurrent.atomic包中以下三个类是以原子方式更新数组:
AtomicIntegerArray:原子更新整型数组里的元素。
AtomicLongArray:原子更新长整型数组里的元素。
AtomicReferenceArray:原子更新引用类型数组里的元素。
以AtomicIntegerArray为例,其主要是提供原子的方式更新数组里的整型,其常用方法如下
int addAndGet(int i, int delta):以原子方式将输入值与数组中索引i的元素相加。
boolean compareAndSet(int i, int expect, int update):如果当前值等于预期值,则以原子方式将数组位置i的元素设置成update值。
如何以原子方式更新引用?
解:java.util.concurrent.atomic包中以下三个类是以原子方式更新引用:
AtomicReference:原子更新引用类型。
AtomicReferenceFieldUpdater:原子更新引用类型里的字段。
AtomicMarkableReference:原子更新带有标记位的引用类型。
如何以原子方式更新字段?
解:java.util.concurrent.atomic包中以下三个类是以原子方式更新字段:
AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
AtomicLongFieldUpdater:原子更新长整型字段的更新器。
AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更数据和数据的版本号,可以解决使用CAS进行原子更新时,可能出现的ABA问题。
Java运行时数据区域(JVM管理的内存结构)都有哪些?哪些是线程独享的,哪些是线程共享的?
解:Java运行时数据区域包含以下部分:堆、栈、方法区、直接内存、运行时常量池。 在JVM运行时内存区域中,PC寄存器、虚拟机栈和本地方法栈是线程独享的。 而Java堆、方法区是线程共享的。 但是值得注意的是,Java堆其实还未每一个线程单独分配了一块TLAB空间,这部分空间在分配时是线程独享的,在使用时是线程共享的。
堆和栈的区别是什么?
解:堆和栈(虚拟机栈)是完全不同的两块内存区域,一个是线程独享的,一个是线程共享的,二者之间最大的区别就是存储的内容不同:
堆中主要存放对象实例。
栈(局部变量表)中主要存放各种基本数据类型、对象的引用。 其实还有一些其他方面的不同,但是我认为都不是最重要的,大家可以自行百度下,扩充下知识面。
数组保存在堆上还是栈上?
解:在Java中,数组同样是一个对象,所以对象在内存中如何存放同样适用于数组; 所以,数组的实例是保存在堆中,而数组的引用是保存在栈上的。
除了JVM运行时内存以外,还有什么区域可以用吗?
解:除了我们前面介绍的虚拟机运行时数据区以外,还有一部分内存也被频繁使用,他不是运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,他就是——直接内存。
直接内存的分配不受Java堆大小的限制,但是他还是会收到服务器总内存的影响。
在JDK 1.4中引入的NIO中,引入了一种基于Channel和Buffer的I/O方式,他可以使用Native函数直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的应用进行操作。
Java中的对象创建有多少种方式?
解:Java中有很多方式可以创建一个对象,最简单的方式就是使用new关键字。
User user = new User(); )
除此以外,还可以使用反射机制创建对象: User user = User.class.newInstance();
或者使用Constructor类的newInstance: Constructor<User> constructor = User.class.getConstructor();
User user = constructor.newInstance();
除此之外还可以使用clone方法和反序列化的方式,这两种方式不常用并且代码比较复杂,就不在这里展示了,感兴趣的可以自行了解下。
Java中对象创建的过程是怎么样的?
解:对于一个普通的Java对象的创建,大致过程如下:
1、虚拟机遇到new指令,到常量池定位到这个类的符号引用。
2、检查符号引用代表的类是否被加载、解析、初始化过。
3、虚拟机为对象分配内存。
4、虚拟机将分配到的内存空间都初始化为零值。
5、虚拟机对对象进行必要的设置。
6、执行方法,成员变量进行初始化。
所有对象都分配在堆上吗?
解:Java堆中主要保存了对象实例,但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
其实,在编译期间,JIT会对代码做很多优化。其中有一部分优化的目的就是减少内存堆分配压力,其中一种重要的技术叫做逃逸分析。
如果JIT经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。
什么是逃逸分析?
解:逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
例如:
public static String craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
sb是一个方法内部变量,上述代码中并没有将他直接返回,这样这个StringBuffer有不会被其他方法所改变,这样它的作用域就只是在方法内部。我们就可以说这个变量并没有逃逸到方法外部。
什么是栈上分配?
解:有了逃逸分析,我们可以判断出一个方法中的变量是否有可能被其他线程所访问或者改变,那么基于这个特性,JIT就可以做一些优化:
* 同步省略
* 标量替换
* 栈上分配
所谓栈上分配,就是将对象直接在栈上进行内存分配,但是由于技术还不成熟,HotSopt中,栈上分配并没有正在的进行实现,而是通过标量替换来实现的。
什么是标量替换?
解:标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
public static void main(String[] args) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
private int x;
private int y;
}
以上代码中,point对象并没有逃逸出alloc方法,并且point对象是可以拆解成标量的。那么,JIT就会不会直接创建Point对象,而是直接使用两个标量int x ,int y来替代Point对象。
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x="+x+"; point.y="+y);
}
可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。 通过标量替换,原本的一个对象,被替换成了多个成员变量。而原本需要在堆上分配的内存,也就不再需要了,完全可以在本地方法栈中完成对成员变量的内存分配。
虚拟机中的堆一定是线程共享的吗?
解:为了保证对象的内存分配过程中的线程安全性,HotSpot虚拟机提供了一种叫做TLAB(Thread Local Allocation Buffer)的技术。
在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,当需要分配内存时,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。
所以,“堆是线程共享的内存区域”这句话并不完全正确,因为TLAB是堆内存的一部分,他在读取上确实是线程共享的,但是在内存分分配上,是线程独享的。
TLAB的空间其实并不大,所以大对象还是可能需要在堆内存中直接分配。那么,对象的内存分配步骤就是先尝试TLAB分配,空间不足之后,再判断是否应该直接进入老年代,然后再确定是再eden分配还是在老年代分配。
什么是TLAB?
解:TLAB是虚拟机在堆内存的eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。
注意到上面的描述中”线程专属”、”只给当前线程使用”、”每个线程单独拥有”的描述了吗?
所以说,因为有了TLAB技术,堆内存并不是完完全全的线程共享,其eden区域中还是有一部分空间是分配给线程独享的。
这里值得注意的是,我们说TLAB是线程独享的,但是只是在“分配”这个动作上是线程独占的,至于在读取、垃圾回收等动作上都是线程共享的。而且在使用上也没有什么区别。
也就是说,虽然每个线程在初始化时都会去堆内存中申请一块TLAB,并不是说这个TLAB区域的内存其他线程就完全无法访问了,其他线程的读取还是可以的,只不过无法在这个区域中分配内存而已。
并且,在TLAB分配之后,并不影响对象的移动和回收,也就是说,虽然对象刚开始可能通过TLAB分配内存,存放在Eden区,但是还是会被垃圾回收或者被移到Survivor Space、Old Gen等。
TLAB带来的问题?
解:虽然在一定程度上,TLAB大大的提升了对象的分配速度,但是TLAB并不是就没有任何问题的。 前面我们说过,因为TLAB内存区域并不是很大,所以,有可能会经常出现不够的情况。在《实战Java虚拟机》中有这样一个例子:
比如一个线程的TLAB空间有100KB,其中已经使用了80KB,当需要再分配一个30KB的对象时,就无法直接在TLAB中分配,遇到这种情况时,有两种处理方案:
1、如果一个对象需要的空间大小超过TLAB中剩余的空间大小,则直接在堆内存中对该对象进行内存分配。
2、如果一个对象需要的空间大小超过TLAB中剩余的空间大小,则废弃当前TLAB,重新申请TLAB空间再次进行内存分配。
以上两个方案各有利弊,如果采用方案1,那么就可能存在着一种极端情况,就是TLAB只剩下1KB,就会导致后续需要分配的大多数对象都需要在堆内存直接分配。
如果采用方案2,也有可能存在频繁废弃TLAB,频繁申请TLAB的情况,而我们知道,虽然在TLAB上分配内存是线程独享的,但是TLAB内存自己从堆中划分出来的过程确实可能存在冲突的,所以,TLAB的分配过程其实也是需要并发控制的。而频繁的TLAB分配就失去了使用TLAB的意义。
为了解决这两个方案存在的问题,虚拟机定义了一个refill_waste的值,这个值可以翻译为“最大浪费空间”。
当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配。
前面的例子中,TLAB总空间100KB,使用了80KB,剩余20KB,如果设置的refill_waste的值为25KB,那么如果新对象的内存大于25KB,则直接堆内存分配,如果小于25KB,则会废弃掉之前的那个TLAB,重新分配一个TLAB空间,给新对象分配内存。
TLAB和栈上分配的关系和区别?
解:二者其实并没有什么太大的关系,很多人爱拿两者来比较,是因为有一个误区,就是认为二者都是避免了对象在堆上分配。
其实这种想法是错的。TLAB并没有避免堆上分配内存,分配还是在堆上的,只不过是eden区中的一块特殊区域。而栈上分配才是避免了在堆上分配内存。
在内存分配过程中,会先尝试栈上分配,不满足要求的话,会进行TLAB分配,然后在进行正常分配的。
什么是方法区?
解:在Java虚拟机中,方法区是可供线程共享的运行时内存区域,它存储了每一个类的结构信息,例如运行时常量池( Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法。
方法区在虚拟机启动的时候被创建,虽然方法区是堆的逻辑组成部分,但是简单的虚拟机实现 可以选择在这个区域不实现垃圾收集。
但是Java 虚拟机规范也不限定实现方法区的内存位置和编译代码的管理策略。
方法区的容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。
方法区在实际内存空间中可以是不连续的。Java 虚拟机实现应当提供给程序员或者最终用户调节方法区初始容量的手段,对于可以动态扩展和收缩方法区来说,则应当提供调节其最大、最小容量的手段。
从这些描述可以看出来,其实在Java虚拟机规范中,对于方法区的要求还是比较宽松的,只要求他要随着JVM启动而创建,并且是线程共享的以及规定了其中保存的内容。
但是并没有规定他的具体实现方式。所以在不同的虚拟机类型、甚至不同的版本中实现方式也不尽相同。
HotSpot 虚拟机在JDK1.8以前中方法区是怎么实现的?
解:Java虚拟规范中说:方法区在虚拟机启动的时候被创建,虽然方法区是堆的逻辑组成部分,但是简单的虚拟机实现可以选择在这个区域不实现垃圾收集。
那么,HotSpot虚拟机为了实现方法区,提供了一块非堆区域,叫做永久代。
永久代这个名字听上去和堆内存中的新生代、老年代有点类似,但是他们并不是一回事儿,永久代只是HotSpot虚拟机中的一个概念。
在HotSpot虚拟机中,按照内存用途将内存空间划分为堆和非堆。其中堆空间用于对象分配,根据对象的年龄,又可以划分为新生代和老年代。而非堆内存就包含了永久代(或者叫方法区)。
之所以起了一个和新生代、老年代类似的名字,我认为其实是为了说明这个区域也是可以被垃圾回收的。
永久代一段连续的内存空间,我们在 JVM 启动之前可以通过设置 -XX:MaxPermSize 的值来控制永久代的大小 所以,方法区是Java虚拟机规范中的定义,是一种规范。而永久代是HotSpot的一种实现,其他的虚拟机实现并没有永久带这一说法。
在JDK 1.8之前的实现中,HotSpot 使用永久代实现方法区,HotSpot 使用 GC分代来实现方法区内存回收。
HotSpot 虚拟机在JDK1.8以后,方法区是怎么实现的?
解:在JDK 1.8之前的实现中,HotSpot 使用永久代实现方法区,HotSpot 使用 GC分代来实现方法区内存回收。
HotSpot虚拟机在1.8之后已经移除了永久代,改为元空间,类的元信息被存储在元空间中。和永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的系统可用内存。
因为移除了永久代,所以方法区的实现自然就发生了变化。虚拟机规范中规定的方法区中存放的是一些描述性信息,即元数据,这些元数据便不能存放在永久代了,就需要存在再JDK 1.8提供的元空间中。
所以,在HotSpot 虚拟机在JDK1.8以后,方法区是通过元空间实现的
什么是Class常量池?
https://blog.csdn.net/w372426096/article/details/106221798
什么是运行时常量池?
解:运行时常量池( Runtime Constant Pool)是每一个类或接口的常量池( Constant_Pool)的运行时表示形式。
它包括了若干种不同的常量:从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。运行时常量池扮演了类似传统语言中符号表( SymbolTable)的角色,不过它存储数据范围比通常意义上的符号表要更为广泛。
每一个运行时常量池都分配在 Java 虚拟机的方法区之中,在类和接口被加载到虚拟机后,对应的运行时常量池就被创建出来。
以上,是Java虚拟机规范中关于运行时常量池的定义。
运行时常量池、Class常量池、字符串常量池的区别与联系?
解:虚拟机启动过程中,会将各个class文件中的常量池载入到运行时常量池中。
所以, Class常量池只是一个媒介场所。在JVM真的运行时,需要把常量池中的常量加载到内存中,进入到运行时常量池。
字符串常量池可以理解为运行时常量池分出来的部分。加载时,对于class的静态常量池,如果字符串会被装到字符串常量池中。
运行时常量池在JDK各个版本中的实现?
解:根据Java虚拟机规范约定:每一个运行时常量池都在Java虚拟机的方法区中分配,在加载类和接口到虚拟机后,就创建对应的运行时常量池。
在不同版本的JDK中,运行时常量池所处的位置也不一样。以HotSpot为例: 在JDK 1.7之前,方法区位于堆内存的永久代中,运行时常量池作为方法区的一部分,也处于永久代中。
因为使用永久代实现方法区可能导致内存泄露问题,所以,从JDK1.7开始,JVM尝试解决这一问题,在1.7中,将原本位于永久代中的运行时常量池移动到堆内存中。
(永久代在JDK 1.7并没有完全移除,只是原来方法区中的运行时常量池、类的静态变量等移动到了堆内存中。)
在JDK 1.8中,彻底移除了永久代,方法区通过元空间的方式实现。随之,运行时常量池也在元空间中实现。
运行时常量池中常量的来源?
解:运行时常量池中包含了若干种不同的常量: 编译期可知的字面量和符号引用(来自Class常量池) 运行期解析后可获得的常量(如String的intern方法) 所以,运行时常量池中的内容包含:Class常量池中的常量、字符串常量池中的内容
什么是直接内存?
解:直接内存并不是虚拟机运行时数据区域的一部分,也不是Java虚拟机规范中定义的内存区域。
但是这部分内容也至关重要并且经常被使用,并且也能导致OOM, JDK1.4 加入了新的 NIO 机制,目的是防止 Java 堆 和 Native 堆之间往复的数据复制带来的性能损耗,此后 NIO 可以使用 Native函数直接在堆外分配内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。
直接内存区域是全局共享的内存区域,并且可以进行自动内存管理(GC),但机制并不完善。 本机的 Native 堆(直接内存) 不受 JVM 堆内存大小限制,但是受到本机总内存的大小以及处理器寻址空间的限制。
HotSpot的Java的对象模型是什么
解:HotSpot JVM设计了一个OOP-Klass Model。OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型。
每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。
Java对象的内存布局是怎样的?
解:根据HotSpot虚拟机的OOP-Klass Model,中,对象在内存中存储的布局可以分为三块区域:对象头、实例数据和对齐填充。
对象头包含了两部分内容:_mark和_metadata 先说第一部分:Mark Word 对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
对markword的设计方式上,非常像网络协议报文头:将mark word划分为多个比特位区间,并在不同的对象状态下赋予比特位不同的含义。下图描述了在32位虚拟机上,在对象不同状态时 mark word各个比特位区间的含义。
再说第二部分:metadata 这部分是类型指针,其中包含_klass和_compressed_klass,其中_klass是普通指针,_compressed_klass是压缩类指针。
这两个指针都指向instanceKlass对象,它用来描述对象的具体类型。
实例数据部分就是对象真正存储的有效信息,也是在程序代码中定义的各种类型的字段内容