2-《Java进阶》
- 一. java多线程(非常重要)
- 1.1. 线程
- java多线程实现方式主要有:
- 1.继承Thread
- 2.实现Runnable
- 3.实现Callable
- Runnable 与 Callable的区别:
- 1.2. 线程的状态有哪些?
- 1.3. 线程的状态转换及控制
- 1.4. Java如何正确停止线程
- 1.5 线程池(非常重要)
- 1.6. Java锁机制
- 1.7. Java中的主流锁
- 1.8. Java中Volatile关键字(重要)
- 1.9.synchronized同步原理
- 2 JVM(java虚拟机)
- 2.1. 运行时数据区域
- 2.1. GC机制(重要)
- 2.3. 类加载过程
- 2.4. 双亲委派模型
一. java多线程(非常重要)
1.1. 线程
线程和进程的区别?
线程是CPU调度的最小单位,一个进程中可以包含多个线程,在Android中,一个进程通常是一个App,App中会有一个主线程,主线程可以用来操作界面元素,如果有耗时的操作,必须开启子线程执行,不然会出现ANR,除此以外,进程间的数据是独立的,线程间的数据可以共享。
java多线程实现方式主要有:
1.继承Thread
优点 : 方便传参,可以在子类添加成员变量,通过方法设置参数或构造函数传参。
缺点:
1.因为Java不支持多继承,所以继承了Thread类以后,就无法继承其他类。
2.每次都要新建一个类,不支持通过线程池操作,创建和销毁线程对资源的开销比较大。
3.从代码结构上讲,为了启动一个线程任务,都要创建一个类,耦合性太高。
4.无法获取线程任务的返回结果。
Thread syncTask = new Thread() {
@Override
public void run() {
// 执行耗时操作
}
};
syncTask.start();//启动线程
2.实现Runnable
优点 : 此方式可以继承其他类。也可以使用线程池管理,节约资源。创建线程代码的耦合性较低。推荐使用此种方式创建线程。
缺点: 不方便传参,只能使用主线程中用final修饰的变量。其次是无法获取线程任务的返回结果。
//写法1:集成Runnable接口定义任务类
public class ThreadTask implements Runnable {
@Override
public void run() {
while(true) {
System.out.println(Thread.currentThread().getName()+" is running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
//在其他地方使用
new Thread(new ThreadTask ()).start();
//写法2:匿名内部类写法
new Thread(new Runnable() {
@Override
public void run() {
//做操作
}
}).start();
## 3.实现Callable
此种方式创建线程底层源码也是使用实现Runnable接口的方式实现的,所以不是一种新的创建线程的方式,只是在实现Runnable接口方式创建线程的基础上,同时实现了Future接口,实现有返回值的创建线程。
```java
//写法1:集成Runnable接口定义任务类
public class ThreadTask implements Runnable {
@Override
public void run() {
while(true) {
System.out.println(Thread.currentThread().getName()+" is running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
//在其他地方使用
new Thread(new ThreadTask ()).start();
//写法2:匿名内部类写法
new Thread(new Runnable() {
@Override
public void run() {
//做操作
}
}).start();
3.实现Callable
此种方式创建线程底层源码也是使用实现Runnable接口的方式实现的,所以不是一种新的创建线程的方式,只是在实现Runnable接口方式创建线程的基础上,同时实现了Future接口,实现有返回值的创建线程。
Runnable 与 Callable的区别:
1. Runnable是在JDK1.0的时候提出的多线程的实现接口,而Callable是在JDK1.5之后提出的;
2. Runnable 接口之中只提供了一个run()方法,并且没有返回值;
3. Callable接口提供有call(),可以有返回值;
扩展:
Callable接口支持返回执行结果,此时需要调用FutureTask.get()方法实现,此方法会阻塞主线程直到获取‘将来’结果;
当不调用此方法时,主线程不会阻塞
public class CallableImpl implements Callable<String> {
public CallableImpl(String acceptStr) {
this.acceptStr = acceptStr;
}
private String acceptStr;
@Override
public String call() throws Exception {
// 任务阻塞 1 秒
Thread.sleep(1000);
return this.acceptStr + " append some chars and return it!";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<String> callable = new CallableImpl("my callable test!");
FutureTask<String> task = new FutureTask<>(callable);
long beginTime = System.currentTimeMillis();
// 创建线程
new Thread(task).start();
// 调用get()阻塞主线程,反之,线程不会阻塞
String result = task.get();
long endTime = System.currentTimeMillis();
System.out.println("hello : " + result);
System.out.println("cast : " + (endTime - beginTime) / 1000 + " second!");
}
}
//执行结果
hello : my callable test! append some chars and return it!
cast : 1 second!
总结:
根据Oracle提供的JAVA官方文档的说明,Java创建线程的方法只有两种方式,即继承Thread类和实现Runnable接口。其他所有创建线程的方式,底层都是使用这两种方式中的一种实现的,比如通过线程池、通过匿名类、通过lambda表达式、通过Callable接口等等,全是通过这两种方式中的一种实现的。所以我们在掌握线程创建的时候,必须要掌握的只有这两种,通过文章中优缺点的分析,这两种方法中,最为推荐的就是实现Runnable接口的方式去创建线程。
1.2. 线程的状态有哪些?
Java中定义线程的状态有6种,可以查看Thread类的State枚举:
public static enum State
{
NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED;
private State() {}
}
- 初始(NEW):新创建了一个线程对象,还没调用start方法;
- 运行(RUNNABLE):java线程中将就绪(ready)和运行中(running)统称为运行(RUNNABLE)。线程创建后调用了该对象的start方法,此时处于就绪状态,
当获得CPU时间片后变为运行中状态; - 阻塞(BLOCKED):表现线程阻塞于锁;
- 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断);
- 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定时间后自行返回;
- 终止(TERMINATED):表示该线程已经执行完毕。
状态详细说明:
-
初始状态(NEW)
实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态。 -
就绪状态(RUNNABLE之READY)
就绪状态只是说你资格运行,调度程序没有挑选到你,你就永远是就绪状态。
调用线程的start()方法,此线程进入就绪状态。
当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。
当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。
锁池里的线程拿到对象锁后,进入就绪状态。
运行中状态(RUNNABLE之RUNNING)
线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一的一种方式。
-
阻塞状态(BLOCKED)
阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。 -
等待(WAITING)
处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。 -
超时等待(TIMED_WAITING)
处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。 -
终止状态(TERMINATED)
当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。
在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。
1.3. 线程的状态转换及控制
主要由这几个方法来控制:sleep、join、yield、wait、notify以及notifyAll。
wait() / notify() / notifyAll()
wait(),notify(),notifyAll() 是定义在Object类的实例方法,用于控制线程状态,三个方法都必须在synchronized 同步关键字所限定的作用域中调用(只能在同步控制方法或者同步控制块中使用),否则会报错 java.lang.IllegalMonitorStateException。
join() / sleep() / yield()
join()
如果线程A调用了线程B的join方法,线程A将被阻塞,等待线程B执行完毕后线程A才会被执行。这里需要注意一点的是,join方法必须在线程B的start方法调用之后调用才有意义。join方法的主要作用就是实现线程间的同步,它可以使线程之间的并行执行变为串行执行。
sleep()
当线程A调用了 sleep方法,则线程A将被阻塞,直到指定睡眠的时间到达后,线程A才会重新被唤起,进入就绪状态。
public class Test {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "---" + i);
try {
Thread.sleep(1000); // 阻塞当前线程1s
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
yield() 当线程A调用了yield方法,它可以暂时放弃处理器,但是线程A不会被阻塞,而是进入就绪状态。执行了yield方法的线程什么时候会继续运行由线程调度器来决定。
public class YieldThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "---" + i);
// 主动放弃
Thread.yield();
}
}
}
sleep方法和wait方法的区别是什么?
wait方法既释放cpu,又释放锁。 sleep方法只释放cpu,但是不释放锁。
sleep 方法是Thread类的一个静态方法,其作用是使运行中的线程暂时停止指定的毫秒数,从而该线程进入阻塞状态并让出处理器,将执行的机会让给其他线程。但是这个过程中监控状态始终保持,当sleep的时间到了之后线程会自动恢复。
wait 方法是Object类的方法,它是用来实现线程同步的。当调用某个对象的wait方法后,当前线程会被阻塞并释放同步锁,直到其他线程调用了该对象的 notify 方法或者 notifyAll 方法来唤醒该线程。所以 wait 方法和 notify(或notifyAll)应当成对出现以保证线程间的协调运行。
1.4. Java如何正确停止线程
注意:Java中线程的stop()、suspend()、resume()三个方法都已经被弃用,所以不再使用stop()方法停止线程。
我们只能调用线程的interrupt()方法通知系统停止线程,并不能强制停止线程。线程能否停止,何时停止,取决于系统。
1.5 线程池(非常重要)
线程池的地位十分重要,基本上涉及到跨线程的框架都使用到了线程池,比如说OkHttp、RxJava、LiveData以及协程等。
与新建一个线程相比,线程池的特点?
- 节省开销: 线程池中的线程可以重复利用。
- 速度快:任务来了就能开始,省去创建线程的时间。
- 线程可控:线程数量可空和任务可控。
- 功能强大:可以定时和重复执行任务。
ExecutorService简介
通常来说我们说到线程池第一时间想到的就是它:ExecutorService,它是一个接口,其实如果要从真正意义上来说,它可以叫做线程池的服务,因为它提供了众多接口api来控制线程池中的线程,而真正意义上的线程池就是:ThreadPoolExecutor,它实现了ExecutorService接口,并封装了一系列的api使得它具有线程池的特性,其中包括工作队列、核心线程数、最大线程数等。
线程池(ThreadPoolExecutor)中的几个参数是什么意思?
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {//...}
参数解释如下(重要):
corePoolSize:核心线程数量,不会释放。
maximumPoolSize:允许使用的最大线程池数量,非核心线程数量,闲置时会释放。
keepAliveTime:闲置线程允许的最大闲置时间。它起作用必须在一个前提下,就是当线程池中的线程数量超过了corePoolSize时,它表示多余的空闲线程的存活时间,即:多余的空闲线程在超过keepAliveTime时间内没有任务的话则被销毁。而这个主要应用在缓存线程池中
unit:闲置时间的单位。
workQueue:阻塞队列,用来存储已经提交但未被执行的任务,不同的阻塞队列有不同的特性。
threadFactory:线程工厂,用来创建线程池中的线程,通常用默认的即可
handler:通常叫做拒绝策略,1、在线程池已经关闭的情况下 2、任务太多导致最大线程数和任务队列已经饱和,无法再接收新的任务 。在上面两种情况下,只要满足其中一种时,在使用execute()来提交新的任务时将会拒绝,而默认的拒绝策略是抛一个RejectedExecutionException异常
上面的参数理解起来都比较简单,不过workQueue这个任务队列却要再次说明一下,它是一个BlockingQueue对象,而泛型则限定它是用来存放Runnable对象的,刚刚上面讲了,不同的线程池它的任务队列实现肯定是不一样的,所以,保证不同线程池有着不同的功能的核心就是这个workQueue的实现了,细心的会发现在刚刚的用来创建线程池的工厂方法中,针对不同的线程池传入的workQueue也不一样,五种线程池分别用的是什么BlockingQueue:
1、newFixedThreadPool()—>LinkedBlockingQueue 无界的队列
2、newSingleThreadExecutor()—>LinkedBlockingQueue 无界的队列
3、newCachedThreadPool()—>SynchronousQueue 直接提交的队列
4、newScheduledThreadPool()—>DelayedWorkQueue 等待队列
5、newSingleThreadScheduledExecutor()—>DelayedWorkQueue 等待队列
线程池中用到的三种阻塞队列:
- LinkedBlockingQueue:无界的队列
它的容量是 Integer.MAX_VALUE,为 231 -1 ,是一个非常大的值,可以认为是无界队列。FixedThreadPool 和 SingleThreadExecutor 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要 LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。
- SynchronousQueue:直接提交的队列
如果不希望任务在队列中等待而是希望将任务直接移交给工作线程,可使用SynchronousQueue作为等待队列。SynchronousQueue不是一个真正的队列,而是一种线程之间移交的机制。要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接收这个元素。只有在使用无界线程池或者有饱和策略时才建议使用该队列。
- DelayedWorkQueue:等待队列
它对应的线程池分别是 ScheduledThreadPool 和 SingleThreadScheduledExecutor,这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务。
DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构(堆的应用之一就是 优先级队列)。之所以线程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 选择 DelayedWorkQueue,是因为它们本身正是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行。
线程池的种类有哪些:五种功能不一样的线程池:
这样创建线程池的话,我们需要配置一堆东西,非常麻烦。所以,官方也不推荐使用这种方法来创建线程池,而是推荐使用Executors的工厂方法来创建线程池,Executors类是官方提供的一个工厂类,它里面封装好了众多功能不一样的线程池(但底层实现还是通过ThreadPoolExecutor),从而使得我们创建线程池非常的简便,主要提供了如下五种功能不一样的线程池:
newCachedThreadPool() :返回一个可以根据实际情况调整线程池中线程的数量的线程池。即该线程池中的线程数量不确定,是根据实际情况动态调整的。
newFixedThreadPool() :线程池只能存放指定数量的线程池,线程不会释放,可重复利用。
newSingleThreadExecutor() :单线程的线程池。即每次只能执行一个线程任务,多余的任务会保存到一个任务队列中,等待这一个线程空闲,当这个线程空闲了再按FIFO方式顺序执行任务队列中的任务。
newScheduledThreadPool() :可定时和重复执行的线程池。
newSingleThreadScheduledExecutor():同上。和上面的区别是该线程池大小为1,而上面的可以指定线程池的大小。
通过Executors的工厂方法来获取:
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5);
ExecutorService singleThreadPool = Executors.newSingleThreadExecutor();
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(5);
ScheduledExecutorService singleThreadScheduledPool = Executors.newSingleThreadScheduledExecutor();
通过Executors的工厂方法来创建线程池极其简便,其实它的内部还是通过new ThreadPoolExecutor(…)的方式创建线程池的,我们看一下这些工厂方法的内部实现:
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
线程池ThreadPoolExecutor的使用
使用线程池,其中涉及到一个极其重要的方法,即:
execute(Runnable command)
该方法意为执行给定的任务,该任务处理可能在新的线程、已入池的线程或者正调用的线程,这由ThreadPoolExecutor的实现决定。
五种线程池使用举例:
- newFixedThreadPool 创建一个固定线程数量的线程池,示例为:
创建了一个线程数为3的固定线程数量的线程池,同理该线程池支持的线程最大并发数也是3,而我模拟了10个任务让它处理,执行的情况则是首先执行前三个任务,后面7个则依次进入任务队列进行等待,执行完前三个任务后,再通过FIFO的方式从任务队列中取任务执行,直到最后任务都执行完毕。
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 10; i++) {
final int index = i;
fixedThreadPool.execute(new Runnable() {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
Log.v("zxy", "线程:"+threadName+",正在执行第" + index + "个任务");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
- newSingleThreadExecutor
创建一个只有一个线程的线程池,每次只能执行一个线程任务,多余的任务会保存到一个任务队列中,等待线程处理完再依次处理任务队列中的任务,示例为:
ExecutorService singleThreadPool = Executors.newSingleThreadExecutor();
for (int i = 1; i <= 10; i++) {
final int index = i;
singleThreadPool.execute(new Runnable() {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
Log.v("zxy", "线程:"+threadName+",正在执行第" + index + "个任务");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
其实我们通过newSingleThreadExecutor()和newFixedThreadPool()的方法发现,创建一个singleThreadExecutorPool实际上就是创建一个核心线程数和最大线程数都为1的fixedThreadPool。
- newCachedThreadPool
创建一个可以根据实际情况调整线程池中线程的数量的线程池,为了体现该线程池可以自动根据实现情况进行线程的重用,而不是一味的创建新的线程去处理任务,我设置了每隔1s去提交一个新任务,这个新任务执行的时间也是动态变化的,示例为
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
for (int i = 1; i <= 10; i++) {
final int index = i;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
cachedThreadPool.execute(new Runnable() {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
Log.v("zxy", "线程:" + threadName + ",正在执行第" + index + "个任务");
try {
long time = index * 500;
Thread.sleep(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
4. newScheduledThreadPool
创建一个可以定时或者周期性执行任务的线程池,示例为:
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(3);
//延迟2秒后执行该任务
scheduledThreadPool.schedule(new Runnable() {
@Override
public void run() {
}
}, 2, TimeUnit.SECONDS);
//延迟1秒后,每隔2秒执行一次该任务
scheduledThreadPool.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
}
}, 1, 2, TimeUnit.SECONDS);
- newSingleThreadScheduledExecutor
创建一个可以定时或者周期性执行任务的线程池,该线程池的线程数为1,示例为
ScheduledExecutorService singleThreadScheduledPool = Executors.newSingleThreadScheduledExecutor();
//延迟1秒后,每隔2秒执行一次该任务
singleThreadScheduledPool.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
Log.v("zxy", "线程:" + threadName + ",正在执行");
}
},1,2,TimeUnit.SECONDS);
这个和上面的没什么太大区别,只不过是线程池内线程数量的不同,效果为:每隔2秒就会执行一次该任务
自定义线程池ThreadPoolExecutor(自行了解)
线程池的状态:
-
RUNNING:线程池一旦被创建,就处于 RUNNING 状态,任务数为 0,能够接收新任务,对已排队的任务进行处理。
-
SHUTDOWN:不接收新任务,但能处理已排队的任务。调用线程池的 shutdown() 方法,线程池由 RUNNING 转变为 SHUTDOWN 状态。
-
STOP:不接收新任务,不处理已排队的任务,并且会中断正在处理的任务。调用线程池的 shutdownNow() 方法,线程池由(RUNNING 或 SHUTDOWN ) 转变为 STOP 状态。
-
TIDYING:SHUTDOWN 状态下,任务数为 0, 其他所有任务已终止,线程池会变为 TIDYING 状态,会执行 terminated() 方法。线程池中的 terminated() 方法是空实现,可以重写该方法进行相应的处理。
线程池在 SHUTDOWN 状态,任务队列为空且执行中任务为空,线程池就会由 SHUTDOWN 转变为 TIDYING 状态。
线程池在 STOP 状态,线程池中执行中任务为空时,就会由 STOP 转变为 TIDYING 状态。 -
TERMINATED:线程池彻底终止。线程池在 TIDYING 状态执行完 terminated() 方法就会由 TIDYING 转变为 TERMINATED 状态。
线程池的停止
关于线程池的停止,ExecutorService为我们提供了两个方法:shutdown和shutdownNow,这两个方法各有不同,可以根据实际需求方便的运用,如下: -
shutdown() 平滑的关闭线程池。(如果还有未执行完的任务,就等待它们执行完)。
-
shutdownNow() 简单粗暴的关闭线程池。(没有执行完的任务也直接关闭)。
线程池的工作流程
简单说:
- 任务来了,优先考虑核心线程。
- 核心线程满了,进入阻塞队列。
- 阻塞队列满了,考虑非核心线程。
- 非核心线程满了,再触发拒绝任务。
详细说明:
1 当一个任务通过submit或者execute方法提交到线程池的时候,如果当前池中线程数(包括闲置线程)小于coolPoolSize,则创建一个线程执行该任务。
2 如果当前线程池中线程数已经达到coolPoolSize,则将任务放入等待队列。
3 如果任务不能入队,说明等待队列已满,若当前池中线程数小于maximumPoolSize,则创建一个临时线程(非核心线程)执行该任务。
4 如果当前池中线程数已经等于maximumPoolSize,此时无法执行该任务,根据拒绝执行策略处理。
注意:当池中线程数大于coolPoolSize,超过keepAliveTime时间的闲置线程会被回收掉。回收的是非核心线程,核心线程一般是不会回收的。如果设置allowCoreThreadTimeOut(true),则核心线程在闲置keepAliveTime时间后也会被回收。
任务队列是一个阻塞队列,线程执行完任务后会去队列取任务来执行,如果队列为空,线程就会阻塞,直到取到任务。
其它面试题:
1.当线程池的核心线程数量过大或者过小的影响?
首先,多线程编程中一般线程的个数都大于CPU核心的个数,而一个CPU核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效的执行,CPU采取的策略是为了每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让其他线程使用,这个过程就属于一次上下文切换。
当线程池中核心线程数量过大时,线程与线程之间会争取CPU资源,这样就会导致上下文切换。过多的上下文切换会增加线程的执行时间,影响了整体执行的效率;
当线程池中的核心线程数量过少时,如果同一时间有大量任务需要处理,可能会导致大量任务在任务队列中排队,甚至会出现队列满了之后任务无法执行的情况,或者大量任务堆积在任务队列导致内存溢出(OOM)。
2.CPU密集型和IO密集型?
CPU密集型:比如加密、解密、压缩、计算等一系列需要大量耗费 CPU 资源的任务。CPU 密集型任务应配置尽可能小的线程,如配置CPU核数 + 1个线程的线程池。如果设置过多的线程数,假设设置的线程数是 CPU 核心数的 2 倍以上,因为计算任务非常重,会占用大量的 CPU 资源,所以这时 CPU 的每个核心工作基本都是满负荷的,而我们又设置了过多的线程,每个线程都想去利用 CPU 资源来执行自己的任务,这就会造成不必要的上下文切换,此时线程数的增多并没有让性能提升,反而由于线程数量过多会导致性能下降。
I/O密集型:比如数据库、文件的读写,网络通信等任务,这种任务的特点是并不会特别消耗 CPU 资源,但是 IO 操作很耗时,总体会占用比较多的时间。由于 IO 密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如CPU核数 * 2。因为 IO 读写速度相比于 CPU 的速度而言是比较慢的,如果我们设置过少的线程数,就可能导致 CPU 资源的浪费。而如果我们设置更多的线程数,那么当一部分线程正在等待 IO 的时候,它们此时并不需要 CPU 来计算,那么另外的线程便可以利用 CPU 去执行其他的任务,互不影响,这样的话在工作队列中等待的任务就会减少,可以更好地利用资源。
混合型任务:既包含CPU密集型又包含I/O密集型。
3.corePoolSize核心线程数,一般设置为多少?
首先要考虑到 CPU 核心数;可以使用下面的方法获取
Runtime.getRuntime().availableProcessor() 方法来获取(可能不准确,作为参考)
在确认了核心数后,再去判断是 CPU 密集型任务还是 IO 密集型任务:
- CPU密集型:核心线程数 = CPU核数 + 1
- IO密集型:核心线程数 = CPU核数 * 2
注:IO密集型(某大厂实践经验)
核心线程数 = CPU核数 / (1-阻塞系数)
其中计算密集型阻塞系数为 0,IO 密集型阻塞系数接近 1,一般认为在 0.8 ~ 0.9 之间。比如 8 核
CPU,按照公式就是 2 / ( 1 - 0.9 ) = 20 个线程数
另外,可参考AsyncTask源码中的设置:
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
// We want at least 2 threads and at most 4 threads in the core pool,
// preferring to have 1 less than the CPU count to avoid saturating
// the CPU with background work
private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
源码有解释的:之所以 减掉这个1,是因为为了避免后台任务将 CPU 资源完全耗尽, 减掉的这个1 是留给我们 主线程 使用的。
1.6. Java锁机制
在java中,解决同步问题,很多时候都会使用到synchronized和Lock,这两者都是在多线程并发时候常使用的锁机制。在JDK1.6后,对synchronized进行了很多优化,如偏向锁、轻量级锁等,synchronized的性能已经与Reentrantlock大致相同,除非要使用Reentrantlock的一些高级功能(实现公平锁、中断锁等),一般推荐使用synchronized关键字来实现加锁机制。
Synchronized 是Java 并发编程中很重要的关键字,另外一个很重要的是 volatile。Syncronized 一次只允许一个线程进入由他修饰的代码段,从而允许他们进行自我保护。进入由Synchronized 保护的代码区首先需要获取 Synchronized 这把锁,其他线程想要执行必须进行等待。Synchronized 锁住的代码区域执行完成后需要把锁归还,也就是释放锁,这样才能够让其他线程使用。
Lock 是 Java并发编程中很重要的一个接口,它要比 Synchronized 关键字更能直译"锁"的概念,Lock需要手动加锁和手动解锁,一般通过 lock.lock() 方法来进行加锁, 通过 lock.unlock() 方法进行解锁。与 Lock 关联密切的锁有 ReetrantLock 和 ReadWriteLock。
ReetrantLock 实现了Lock接口,它是一个可重入锁,内部定义了公平锁与非公平锁。
ReadWriteLock 一个用来获取读锁,一个用来获取写锁。也就是说将文件的读写操作分开,分成2个锁来分配给线程,从而使得多个线程可以同时进行读操作。ReentrantReadWirteLock实现了ReadWirteLock接口,并未实现Lock接口。
Synchronized 的使用
修饰一个方法:即一次只能有一个线程进入该方法,其他线程要想在此时调用该方法,只能排队等候。
实例方法:锁住的是该类的实例对象
静态方法:锁住的是该类的类对象。
public synchronized void goHOme(){
}
public static synchronized void goHOme(){
}
修饰代码块:表示只能有一个线程进入某个代码段
public void numDecrease(Object num){
synchronized (num){
number++;
}
}
修饰一个类:作用的对象是这个类的所有对象,只要是这个类型的class不管有几个对象都会起作用。
class Person {
public void method() {
//锁住的是该类的类对象,如果换成this或其他object,则锁住的是该类的实例对象
synchronized(Person.class) {
// todo
}
}
}
获取对象锁
synchronized(this|object) {}
修饰非静态方法
获取类锁
synchronized(类.class) {}
修饰静态方法
Lock 的使用
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
使用示例:
Lock lock = ...;
lock.lock();
try{
//处理任务
}catch(Exception ex){
}finally{
lock.unlock(); //释放锁
}
Lock lock = ...;
if(lock.tryLock()) {
try{
//处理任务
}catch(Exception ex){
}finally{
lock.unlock(); //释放锁
}
}else {
//如果不能获取锁,则直接做其他事情
}
public void method() throws InterruptedException {
lock.lockInterruptibly();
try {
//.....
}
finally {
lock.unlock();
}
}
一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。
注意,当一个线程获取了锁之后,是不会被interrupt()方法中断的。单独调用interrupt()方法不能中断正在运行过程中的线程,只能中断阻塞过程中的线程。因此当通过lockInterruptibly()方法获取某个锁时,如果不能获取到,只有进行等待的情况下,是可以响应中断的。而用synchronized修饰的话,当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去。
synchronized和Lock的区别?
主要区别:
synchronized是Java中的关键字,是Java的内置实现;Lock是Java中的接口。
synchronized遇到异常会释放锁;Lock需要在发生异常的时候调用成员方法Lock#unlock()方法。
synchronized是不可以中断的,Lock可中断。
synchronized不能去尝试获得锁,没有获得锁就会被阻塞; Lock可以去尝试获得锁,如果未获得可以尝试处理其他逻辑。
synchronized多线程效率不如Lock,不过Java在1.6以后已经对synchronized进行大量的优化,所以性能上来讲,其实差不了多少。
死锁
所谓死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
死锁触发的四大条件?
互斥锁
请求与保持
不可剥夺
循环的请求与等待
简单死锁代码示例:
public class DeadLock {
public static String obj1 = "obj1";
public static String obj2 = "obj2";
public static void main(String[] args){
Thread a = new Thread(new Lock1());
Thread b = new Thread(new Lock2());
a.start();
b.start();
}
}
class Lock1 implements Runnable{
@Override
public void run(){
try{
System.out.println("Lock1 running");
while(true){
synchronized(DeadLock.obj1){
System.out.println("Lock1 lock obj1");
Thread.sleep(3000);//获取obj1后先等一会儿,让Lock2有足够的时间锁住obj2
synchronized(DeadLock.obj2){
System.out.println("Lock1 lock obj2");
}
}
}
}catch(Exception e){
e.printStackTrace();
}
}
}
class Lock2 implements Runnable{
@Override
public void run(){
try{
System.out.println("Lock2 running");
while(true){
synchronized(DeadLock.obj2){
System.out.println("Lock2 lock obj2");
Thread.sleep(3000);
synchronized(DeadLock.obj1){
System.out.println("Lock2 lock obj1");
}
}
}
}catch(Exception e){
e.printStackTrace();
}
}
}
可以看到,Lock1获取obj1,Lock2获取obj2,但是它们都没有办法再获取另外一个obj,因为它们都在等待对方先释放锁,这时就是死锁。
1.7. Java中的主流锁
Java中往往是按照是否含有某一特性来定义锁,我们通过特性将锁进行分组归类:
1.乐观锁 VS 悲观锁
概念:对于同一个数据的并发操作,乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。
乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
根据从上面的概念描述我们可以发现:
- 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
- 调用方式示例:
悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?
乐观锁的主要实现方式 “CAS” 的技术原理:
CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。
CAS算法涉及到三个操作数:
- 需要读写的内存值 V。
- 进行比较的值 A。
- 要写入的新值 B。
当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。
之前提到java.util.concurrent包中的原子类,就是通过CAS来实现了乐观锁,那么我们进入原子类AtomicInteger的源码,看一下AtomicInteger的定义:
根据定义我们可以看出各属性的作用:
- unsafe: 获取并操作内存的数据。
- valueOffset: 存储value在AtomicInteger中的偏移量。
- value: 存储AtomicInteger的int值,该属性需要借助volatile关键字保证其在线程间是可见的。
接下来,我们查看AtomicInteger的自增函数incrementAndGet()的源码时,发现自增函数底层调用的是unsafe.getAndAddInt()。但是由于JDK本身只有Unsafe.class,只通过class文件中的参数名,并不能很好的了解方法的作用,所以我们通过OpenJDK 8 来查看Unsafe的源码:
根据OpenJDK 8的源码我们可以看出,getAndAddInt()循环获取给定对象o中的偏移量处的值v,然后判断内存值是否等于v。如果相等则将内存值设置为 v + delta,否则返回false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。整个“比较+更新”操作封装在compareAndSwapInt()中,在JNI里是借助于一个CPU指令完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值。
后续JDK通过CPU的cmpxchg指令,去比较寄存器中的 A 和 内存中的值 V。如果相等,就把要写入的新值 B 存入内存中。如果不相等,就将内存值 V 赋值给寄存器中的值 A。然后通过Java代码中的while循环再次调用cmpxchg指令进行重试,直到设置成功为止。
CAS虽然很高效,但是它也存在三大问题,这里也简单说一下:
- ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
-
循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
-
只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
2.自旋锁 VS 适应性自旋锁
概念解析:
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。
自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
在自旋锁中另有三种常见的锁形式:TicketLock、CLHlock和MCSlock,本文中仅做名词介绍,不做深入讲解,感兴趣的同学可以自行查阅相关资料。
3.无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
这四种锁是指锁的状态,专门针对synchronized的。在介绍这四种锁状态之前还需要介绍一些额外的知识。
首先为什么Synchronized能实现线程同步?
在回答这个问题之前我们需要了解两个重要的概念:“Java对象头”、“Monitor”。
Java对象头
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?
我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Monitor
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
现在话题回到synchronized,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。
如同我们在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
通过上面的介绍,我们对synchronized的加锁机制以及相关知识有了一个了解,那么下面我们给出四种锁状态对应的的Mark Word内容,然后再分别讲解四种锁状态的思路以及特点:
无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
整体的锁状态升级流程如下:
综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。
4. 公平锁 VS 非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死,缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
直接用语言描述可能有点抽象,这里用一个例子来讲述一下公平锁和非公平锁。
如上图所示,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。每个过来打水的人都要管理员的允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给你锁让你去打水;如果你不是排第一的人,就必须去队尾排队,这就是公平锁。
但是对于非公平锁,管理员对打水的人没有要求。即使等待队伍里有排队等待的人,但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待。如下图所示
接下来我们通过ReentrantLock的源码来讲解公平锁和非公平锁。
根据代码可知,ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。它有公平锁FairSync和非公平锁NonfairSync两个子类。ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
下面我们来看一下公平锁与非公平锁的加锁方法的源码:
通过上图中的源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。
再进入hasQueuedPredecessors(),可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。
综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。
5. 可重入锁 VS 非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析:
在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。
如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。
而为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?我们通过图示和源码来分别解析一下。
还是打水的例子,有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁。
但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。
之前我们说过ReentrantLock和synchronized都是重入锁,那么我们通过重入锁ReentrantLock以及非可重入锁NonReentrantLock的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。
首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
6. 独享锁 VS 共享锁
独享锁和共享锁同样是一种概念。我们先介绍一下具体的概念,然后通过ReentrantLock和ReentrantReadWriteLock的源码来介绍独享锁和共享锁。
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
下图为ReentrantReadWriteLock的部分源码:
我们看到ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
那读锁和写锁的具体加锁方式有什么区别呢?在了解源码之前我们需要回顾一下其他知识。
在最开始提及AQS的时候我们也提到了state字段(int类型,32位),该字段用来描述有多少线程获持有锁。
在独享锁中这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。但是在ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。如下图所示:
- 这段代码首先取到当前锁的个数c,然后再通过c来获取写锁的个数w。因为写锁是低16位,所以取低16位的最大值与当前的c做与运算( int w
= exclusiveCount©; ),高16位和0与运算后是0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。 - 在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
- 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
- 如果当且写线程数为0(那么读线程也应该为0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过CAS增加写线程数失败也返回失败。
- 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!
tryAcquire()除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。
因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。
接着是读锁的代码:
可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。
此时,我们再回头看一下互斥锁ReentrantLock中公平锁和非公平锁的加锁源码:
我们发现在ReentrantLock虽然有公平锁和非公平锁两种,但是它们添加的都是独享锁。根据源码所示,当某一个线程调用lock方法获取锁时,如果同步资源没有被其他线程锁住,那么当前线程在使用CAS更新state成功后就会成功抢占该资源。而如果公共资源被占用且不是被当前线程占用,那么就会加锁失败。所以可以确定ReentrantLock无论读操作还是写操作,添加的锁都是都是独享锁。
1.8. Java中Volatile关键字(重要)
基本概念:Java 内存模型中的可见性、原子性和有序性。
原子性:(原子是世界上的最小单位,具有不可分割性)原子性就是指该操作是不可再分的。不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。比如 a = 1;
非原子性:
也就是整个过程中会出现线程调度器中断操作的现象
类似"a ++"这样的操作不具有原子性,因为它可能要经过以下两个步骤:
(1)取出 a 的值
(2)计算 a+1
如果有两个线程t1,t2都在进行这样的操作。t1在第一步做完之后还没来得及加1操作就被线程调度器中断了,于是t2开始执行,t2执行完毕后t1开始执行第二步(此时t1中a的值可能还是旧值,不是一定的,只有线程t2中a的值没有及时更新到t1中才会出现)。这个时候就出现了错误,t2的操作相当于被忽略了
类似于a += 1这样的操作都不具有原子性。还有一种特殊情况,就是long跟double类型某些情况也不具有原子性
只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
举例:请分析以下哪些操作是原子性操作:
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
其实只有语句1是原子性操作,其他三个语句都不是原子性操作。
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,x++和 x = x+1包括2个操作:读取x的值,进行加1操作,写入新的值。
如何保证原子性?
synchronized、Lock、cas原子类工具
由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。其次cas原子类工具。
共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这个几个线程的共享变量
可见性:一个线程对共享变量值的修改,能够及时的被其它线程看到。也就是一个线程对共享变量修改的结果,另一个线程马上就能看到修改的值。
如何保证可见性?
volatile、synchronized、Lock
要想实现变量的一定可见,可以使用volatile。另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。(其实还有final,但是它初始化后,值不可更改,所以一般不用它实现可见性)。
指令重排:CPU在执行代码时,其实并不一定会严格按照我们编写的顺序去执行,而是可能会考虑一些效率方面的原因,对那些先后顺序无关紧要的代码进行重新排序,这个操作就被称为指令重排。指令重排在单线程情况下没有什么影响,但是在多线程就不一定了。
有序性:程序执行的顺序按照代码先后的顺序执行。
如何保证有序性?
volatile、synchronized、Lock
volatile:
volatile原理:Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的CPU cache 中。
而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。
当一个变量定义为 volatile 之后,将具备两种特性:
1.保证此变量对所有的线程的可见性。当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主内存是不确定的,当其他线程去读取时,此时主内存中可能还是原来的旧值,因此无法保证可见性。
2.禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。
volatile 性能:
volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
volatile为什么不能保证原子性?
简单的说,修改volatile变量分为四步:
1)读取volatile变量到local
2)修改变量值
3)local值写回
4)插入内存屏障,即lock指令,让其他线程可见
这样就很容易看出来,前三步都是不安全的,取值和写回之间,不能保证没有其他线程修改。原子性需要锁来保证。(或者可以理解为线程安全需要锁来保证)。这也就是为什么,volatile只用来保证变量可见性和有序性,但不保证原子性。
1.9.synchronized同步原理
数据同步需要依赖锁,那锁的同步又依赖谁?synchronized给出的答案是在软件层面依赖JVM,而j.u.c.Lock给出的答案是在硬件层面依赖特殊的CPU指令。
package com.paddx.test.concurrent;
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}
}
反编译结果:
2 JVM(java虚拟机)
2.1. 运行时数据区域
1.Jvm内存区域(运行时数据区)划分:
程序计数器:当前线程的字节码执行位置的指示器。内存空间小,线程私有。如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器的值则为 (Undefined)。此内存区域是唯一 一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
Java虚拟机栈:线程私有,生命周期和线程一致。描述的Java方法执行的内存模型,每个方法在执行的同时会创建一个栈帧(Stack Frame),存储着局部变量、操作数栈、动态链接和方法出口等。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。
局部变量表:存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址)
StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度。
OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。
本地方法栈:区别于 Java 虚拟机栈的是,Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈为虚拟机使用到的Native方法服务。也会有StackOverflowError和 OutOfMemoryError 异常。
Java堆:所有对象实例分配的区域。对于绝大多数应用来说,这块区域是 JVM 所管理的内存中最大的一块。线程共享**,主要是存放对象实例和数组。**内部会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。可以位于物理上不连续的空间,但是逻辑上要连续。
OutOfMemoryError:如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出该异常。
方法区:所有已经被虚拟机加载的类的信息、常量、静态变量和即时编辑器编译后的代码数据
详细说明:
程序计数器
程序计数器(Program Counter Register) 是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。
如果线程正在执行的是一个 Native 方法,这个计数器值则为空(Undefined)。
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
Java 虚拟机栈
Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态链接、方法出口等消息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。
其中 64 位长度的 long 和 double 类型的数据会占用两个局部变量空间(Slot),其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在 Java 虚拟机规范中,对这个区域规定了两种异常状态:
如果线程请求的栈深度大于虚拟机所允许的的深度,将抛出 StackOverflowError 异常。
如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
本地方法栈
本地方法栈(Native Method Stack) 与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(例如:Sun HotSpot虚拟机)直接就把虚拟机栈和本地方法栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
Java 堆
对于大多数应用来说,Java 堆(Java Heap) 是 Java 虚拟机所管理的的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
Java堆是垃圾收集器管理的主要区域,从内存回收的角度来看,由于现在收集器基本采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。
从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
方法区
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
运行时常量池(Runtime Constant Pool) 是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时就会抛出 OutOfMemoryError 异常。
扩展:String s1 = "abc"和String s2 = new String(“abc”)的区别,生成对象的情况
指向方法区:"abc"是常量,所以它会在方法区中分配内存,如果方法区已经给"abc"分配过内存,则s1会直接指向这块内存区域。
指向Java堆:new String(“abc”)是重新生成了一个Java实例,它会在Java堆中分配一块内存。
2.1. GC机制(重要)
GC 是 garbage collection 的缩写, 垃圾回收的意思. 也可以是 Garbage Collector, 也就是垃圾回收器.
Java的内存分配与回收全部由JVM垃圾回收进程自动完成。
面试题:“你能不能谈谈,java GC”
1、哪些对象可以被回收。
2、何时回收这些对象。
3、采用什么样的方式回收。
问题1:哪些对象可以被回收?
对象存活判断(如何判断对象可回收/垃圾搜集)
判断一个对象可以回收通常采用的算法是**引用计数算法和可达性分析算法。**由于互相引用导致的计数不好判断,Java采用的可达性算法。
- 引用计数算法
每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,效率很高,但是主流的JVM并没有选用这种算法来判定可回收对象,因为它有一个致命的缺陷,那就是它无法解决对象之间相互循环引用的的问题,对于循环引用的对象它无法进行回收。例:
public class Object {
public Object instance;
public static void main(String[] args) {
// 1
Object objectA = new Object();
Object objectB = new Object();
// 2
objectA.instance = objectB;
objectB.instance = objectA;
// 3
objectA = null;
objectB = null;
}
程序启动后,objectA和objectB两个对象被创建并在堆中分配内存,这两个对象都相互持有对方的引用,除此之外,这两个对象再无任何其他引用,实际上这两个对象已经不可能再被访问(引用被置空,无法访问),但是它们因为相互引用着对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知GC收集器回收它们。
实际上,当第1步执行时,两个对象的引用计数器值都为1;当第2步执行时,两个对象的引用计数器都为2;当第3步执行时,二者都清为空值,引用计数器值都变为1。根据引用计数算法的思想,值不为0的对象被认为是存活的,不会被回收;而事实上这两个对象已经不可能再被访问了,应该被回收。
- 可达性分析算法(根搜索算法)
在主流的JVM实现中,都是通过可达性分析算法来判定对象是否存活的。可达性分析算法的基本思想是:通过一系列被称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots对象没有任何引用链相连,就认为GC Roots到这个对象是不可达的,判定此对象为不可用对象,可以被回收。
在上图中,objectA、objectB、objectC是可达的,不会被回收;objectD、objectE虽然有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
在Java中,可作为GC Roots的对象包括下面几种:
-
虚拟机栈中引用的对象
虚拟机栈中的引用的对象可以作为GC Root。我们程序在虚拟机的栈中执行,每次函数调用调用都是一次入栈。在栈中包括局部变量表和操作数栈,局部变量表中的变量可能为引用类型(reference),他们引用的对象即可作为GC Root。不过随着函数调用结束出栈,这些引用便会消失。 -
方法区中类静态属性引用的对象
简单的说就是我们在类中使用的static声明的引用类型字段,例如:
Class Dog {
private static Object tail;
}
- 方法区中常量引用的对象
简单的说就是我们在类中使用final声明的引用类型字段,例如:
Class Dog {
private final Object tail;
}
- 本地方法栈中引用的对象
就是程序中native本地方法引用的对象。
问题3:采用什么样的方式回收
GC常用算法
可达性分析算法只是知道了哪些对象可以回收,不过垃圾收集显然还需要解决后两个问题,什么时候回收以及如何回收,在根搜索算法的基础上,现代虚拟机的实现当中,垃圾搜集的算法主要有三种,分别是标记-清除算法、复制算法、标记-整理算法,这三种算法都扩充了根搜索算法,不过它们理解起来还是非常好理解的。
标记 -清除算法
就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高(递归与全堆对象遍历);另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,内存的布局自然会乱七八糟。空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法
“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。
标记-整理算法
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记/整理算法唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。
分代搜集算法(重要)
GC 分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。
“分代搜集”算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
新生代GC(minor GC):指发生在新生代的垃圾回收动作,因为Java对象大多都具备朝生夕灭的特点,所以minor GC发生得非常频繁,一般回收速度也比较块。
老年代GC(Major GC/Full GC):指发生在老年代的GC,它的速度会比minor GC慢很多。
**问题2:何时回收这些对象?
回收的时机**
JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。因此GC按照回收的区域又分了两种类型,一种是普通GC(minor GC),一种是全局GC(major GC or Full GC),它们所针对的区域如下。普通GC(minor GC):只针对新生代区域的GC。全局GC(major GC or Full GC):针对年老代的GC,偶尔伴随对新生代的GC以及对永久代的GC。由于年老代与永久代相对来说GC效果不好,而且二者的内存使用增长速度也慢,因此一般情况下,需要经过好几次普通GC,才会触发一次全局GC。
内存模型与回收策略
Java 堆(Java Heap)是JVM所管理的内存中最大的一块,堆又是垃圾收集器管理的主要区域,Java 堆主要分为2个区域-新生代与老年代,其中年轻代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 2个区。
Eden 区
大多数情况下,对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。 通过 Minor GC 之后,Eden 会被清空,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区(若 From 区不够,则直接进入 Old 区)。
Survivor 区
Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,类似于我们交通灯中的黄灯。Survivor 又分为2个区,一个是 From 区,一个是 To 区。每次执行 Minor GC,会将 Eden 区和 From 存活的对象放到 Survivor 的 To 区(如果 To 区不够,则直接进入 Old 区)。Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。Survivor 的预筛选保证,只有经历16次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。
Old 区
老年代占据着2/3的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记——整理算法。
java垃圾收集器:(共7种,着重了解CMS和G1)
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。
从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:
初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark)
并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)
G1收集器
与CMS收集器相比G1收集器有以下特点:
1、空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
2、可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。
使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region 的集合。
G1的新生代收集跟 ParNew 类似,当新生代占用达到一定比例的时候,开始出发收集。和 CMS 类似,G1 收集器收集老年代对象会有短暂停顿。
2.3. 类加载过程
类加载的时机:
- 隐式加载:new创建类的实例
- 显式加载:loaderClass,forName等
- 操作类的静态变量(使用或赋值)
- 调用类的静态方法
- 使用反射方式创建某个类或者接口对象的Class对象。
- 初始化某个类的子类
- 直接使用java.exe命令来运行某个主类
类的加载过程:
其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的。解析阶段可以在初始化之后再开始(运行时绑定或动态绑定或晚期绑定)。
-
加载:ClassLoader通过一个类的完全限定名查找此类字节码文件,并利用字节码文件创建一个class对象。
-
验证:对类的验证。目的在于确保class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身的安全**,主要包括四种验证:文件格式的验证,元数据的验证,字节码验证,符号引用验证。**
-
准备:为类变量分配内存并设置初始值。
为类变量(static修饰的字段变量)分配内存并且设置该类变量的初始值,(如static int i = 5 这里只是将 i 赋值为0,在初始化的阶段再把 i 赋值为5),这里不包含final修饰的static ,因为final在编译的时候就已经分配了。这里不会为实例变量分配初始化,类变量会分配在方法区中,实例变量会随着对象分配到Java堆中。 -
解析:将常量池的符号引用转化为直接引用。
-
初始化:这里是类加载的最后阶段,前面过程都是以虚拟机主导,而初始化阶段开始执行类中定义的Java程序代码,包括类变量的赋值动作和构造函数的赋值。
如果该类具有父类就进行对父类进行初始化,执行其静态初始化器(静态代码块)和静态初始化成员变量。(前面已经对static 初始化了默认值,这里我们对它进行赋值,成员变量也将被初始化)
使用:
卸载
只有加载、验证、准备、初始化和卸载的这个五个阶段的顺序是确定的。
2.4. 双亲委派模型
类加载的机制,以及为什么要这样设计?
类加载的机制是双亲委派模型。大部分Java程序需要使用的类加载器包括:
- 启动类加载器:由C++语言实现,负责加载Java中的核心类。
- 扩展类加载器:负责加载Java扩展的核心类之外的类。
- 应用程序类加载器:负责加载用户类路径上指定的类库
双亲委派模型如下:
双亲委派模型要求出了顶层的启动类加载器之外,其他的类加载器都有自己的父加载器,通过组合实现。
双亲委派模型的工作流程/原理:
简单说:
当一个类加载的任务来临的时候,先交给父类加载器完成,父类加载器交给父父类加载器完成,知道传递给启动类加载器,如果完成不了的情况下,再依次往下传递类加载的任务。
详细解释:
如果一个类收到了类加载的请求,它并不会自己先去加载,而是把这个请求委托给父类加载器去执行,如果父类加载器还存在父类加载器,则进一步向上委托,依次递归,请求最后到达顶层的启动类加载器,如果父类能够完成类的加载任务,就会成功返回,倘若父类加载器无法完成任务,子类加载器才会尝试自己去加载,这就是双亲委派模式。就是每个儿子都很懒,遇到类加载的活都给它爸爸干,直到爸爸说我也做不来的时候,儿子才会想办法自己去加载。
双亲委派模型的优势?这样设计的原因?:
简单说:
双亲委派模型能够保证Java程序的稳定运行,不同层次的类加载器具有不同优先级,所有的对象的父类Object,无论哪一个类加载器加载,最后都会交给启动类加载器,保证安全。
详细解释:
采用双亲委派模式的好处就是Java类随着它的类加载器一起具备一种带有优先级的层次关系,通过这种层级关系可以避免类的重复加载,当父亲已经加载了该类的时候,就没有必要子类加载器(ClassLoader)再加载一次。其次是考虑到安全因素,Java核心API中定义类型不会被随意替换,假设通过网路传递一个名为java.lang.Integer的类,通过双亲委派的的模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字类,发现该类已经被加载,并不会重新加载网络传递过来的java.lang.Integer.而之际返回已经加载过的Integer.class,这样便可以防止核心API库被随意篡改。可能你会想,如果我们在calsspath路径下自定义一个名为java.lang.SingInteger?该类并不存在java.lang中,经过双亲委托模式,传递到启动类加载器中,由于父类加载器路径下并没有该类,所以不会加载,将反向委托给子类加载器,最终会通过系统类加载器加载该类,但是这样做是
java.lang.SecurityException:Prohibited package name: java.lang
为什么叫双亲委派?
parents delegate
parents在英文中是“父母”、“双亲”的意思,但其实表达的是“父母这一辈”的人的意思。实际上这个模型中,只是表达“父母这一辈”的class loader而已,并不是说真的有一个父亲的class loader和一个母亲class loader。
简单来说,就是翻译的人,不仅英语不好,而且也不理解jvm的类加载机制,才会导致翻译成这样