JUC基础
1. 多线程基础
1.1 线程和进程
- 进程:是指一个内存中运行的应用程序,每个进程都有一个独立的内存空间,一个应用程序可以同时运行多个进程;进程也是程序的一次执行过程,是系统运行程序的基本单位;系统运行一个程序即是一个进程从创建、运行到消亡的过程。
- 现程:进程内部的一个独立执行单元;一个进程可以同时并发的运行多个线程,可以理解为一个进程便相当于一个单 CPU 操作系统,而线程便是这个系统中运行的多个任务。
区别:
- 进程:有独立的内存空间,进程中的数据存放空间(堆空间和栈空间)是独立的,至少有一个线程。
- 线程:堆空间是共享的,栈空间是独立的,线程消耗的资源比进程小的多。
1.2 多线程的创建
- 继承
Thread
类,重写run
方法
public class Demo {
@Test
public void test() {
MyThread thread = new MyThread();
thread.start();
}
class MyThread extends Thread {
public void run() {
String name = Thread.currentThread().getName();
System.out.println(name);
}
}
}
- 实现
Runnable
接口
public class Demo {
@Test
public void test() {
MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start();
}
class MyRunnable implements Runnable {
public void run() {
String name = Thread.currentThread().getName();
System.out.println(name);
}
}
}
- 实现
Callable
接口
public class Demo {
@Test
public void test() throws ExecutionException, InterruptedException {
MyCallable myCallable = new MyCallable();
FutureTask futureTask = new FutureTask(myCallable);
futureTask.run();
System.out.println(futureTask.get());
}
class MyCallable implements Callable {
@Override
public String call() throws Exception {
System.out.println("多线程调用了call方法");
return "调用完毕";
}
}
}
1.3 三者的区别
Runnable
接口比Thread
的优势:- 适合多个相同的程序代码的线程去共享同一个资源。
- 可以避免java中的单继承的局限性。
- 增加程序的健壮性,实现解耦操作,代码可以被多个线程共享,代码和数据独立。
- 线程池只能放入实现Runable或callable类线程,不能直接放入继承Thread的类
Runnable
和Callable
的区别:- Callable规定的bai方法是call(),Runnable规定的方法是run().
- Callable的任务执行后可返回值,而Runnable的任务是不能返回值的
- call方法可以抛出异常,run方法不可以
- 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。
1.4 守护线程
java
中的线程分为普通线程和守护线程:
- 用户线程是指用户自定义创建的线程,主线程停止,用户线程不会停止。
- 守护线程当进程不存在或主线程停止,守护线程也会被停止。
开启守护线程的方法:
@Test
public void test() {
MyThread thread = new MyThread();
// 开启守护线程
thread.setDaemon(true);
// ....
}
1.5 线程安全
- 概念:如果有多个线程在同时运行,而这些线程同时运行一段代码。程序每次结果和单线程运行的结果是一样的,变量的值也和预期是一样的,那么就是线程安全的。反之则不安全。
- 引发原因:线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写 操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步, 否则的话就可能影响线程安全。
卖票案例:
/**
* 这种只开了一个线程,单线程跑肯定是没问题,最后票数为0
*/
public class Demo {
@Test
public void test() throws InterruptedException {
Ticket ticket = new Ticket();
Thread thread1 = new Thread(ticket);
thread1.start();
// 睡眠两秒看最后剩下几张票
Thread.sleep(2000);
System.out.println(ticket.ticket);
}
class Ticket implements Runnable {
// 一共有20张票 声明public只是为了好测试
public int ticket = 20;
public void run() {
while (true) {
if (ticket > 0) ticket -- ;
else break;
}
}
}
}
/**
* 但是开了多线程就会出现问题,最后票数不为0
*/
public class Demo {
@Test
public void test() throws InterruptedException {
Ticket ticket = new Ticket();
Thread thread1 = new Thread(ticket);
Thread thread2 = new Thread(ticket);
thread1.start();
thread2.start();
// 睡眠两秒看最后剩下几张票
Thread.sleep(2000);
System.out.println(ticket.ticket);
}
class Ticket implements Runnable {
// 一共有20张票 声明public只是为了好测试
public int ticket = 20;
public void run() {
while (true) {
// 这里睡眠100ms为了方便测试出结果,把线程数增多也是一样的
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (ticket > 0) ticket -- ;
else break;
}
}
}
}
1.6 线程同步
当我们使用多个线程访问同一资源的时候,且多个线程中对资源有写的操作,就容易出现线程安全问题。 要解决上述多线程并发访问一个资源的安全问题,Java中提供了同步机制(synchronized)来解决。
使用synchronized
同步代码块:
Object lock = new Object(); // 创建锁
synchronized(lock){
// 可能会产生线程安全问题的代码
}
使用synchronized
同步方法:
// 同步方法,用的是this锁
public synchronized void method(){
// 可能会产生线程安全问题的代码
}
使用Lock
锁:
Lock lock = new ReentrantLock();
lock.lock();
// 可能会产生线程安全问题的代码
lock.unlock();
1.7 死锁
- 产生原因:同步中嵌套同步,导致锁无法释放。互相等待。
- 解决办法:不在同步中嵌套同步。
1.8 线程状态
有六种状态:
NEW
:线程刚被创建,但未启动成功RUNNABLE
:线程可以在JVM
中运行的状态,可能正在运行,也可能不在运行BLOCKED
:当一个线程试图获取锁,该锁被其他线程持有,进入此状态WAITING
:无线等待状态,无法自动唤醒,需要另一个线程调用notify
TIMED_WAITING
:计时等待状态,常用的有Thread.sleep
TERMINATED
:被终止
wait
和sleep
的区别:
- 对于sleep()方法,首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类中的。
- sleep()方法导致了程序暂停执行指定的时间,让出cpu该其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态。
- wait()是把控制权交出去,然后进入等待此对象的等待锁定池处于等待状态,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备获取对象锁进入运行状态。
- 在调用sleep()方法的过程中,线程不会释放对象锁。而当调用wait()方法的时候,线程会放弃对象锁。
1.9 线程停止
有三种方法:
- 使用标志位
- 使用
interrupt
终止 - 使用
stop
停止
// 标志位
@Test
public void test() {
public boolean exit = true;
Thread thread = new Thread() {
public void run() {
while (exit) {
try {
System.out.println("执行----");
Thraed.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}.start();
Thread.sleep(1000);
exit = false;
}
// interrupt方法
// 如果是阻塞状态会抛出InterruptedException异常
// 否则可以使用isInterrupted来判断是否退出
@Test
public void test() {
public boolean exit = true;
Thread thread = new Thread() {
public void run() {
while (exit) {
try {
System.out.println("执行----");
Thraed.sleep(100);
if (Thread.currntThread().isInterrupted()) {
break;
}
} catch (InterruptedException e) {
e.printStackTrace();
break;
}
}
}
}.start();
Thread.sleep(1000);
thread.interrupt();
}
1.10 线程的优先级
java
通过priority
来控制优先级:
- 范围是
1 - 10
- 默认优先级为
5
- 优先级会默认跟父线程的优先级一致
- 可以通过
join
方法控制线程的先后顺序:join作用是让其他线程变为等待。thread.Join把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行的线程。比如在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行线程B。 - 可以通过
yield
方法暂停当前线程并执行其他线程(可能无效,此方法是让线程回到可运行状态,但是下次cpu可能还会选此线程)
2. 多线程并发特性
2.1 原子性
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
例如银行转账问题,如果不保证原子性,一面减去了钱而另一方没有加钱,会出现很大的问题。
2.2 可见性
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
2.3 有序性
程序执行的顺序按照代码的先后顺序执行
在执行代码时未必按照我们写的代码的顺序来执行,JVM
为了做优化可能会做指令重排序。
这一点在单线程中不会出现问题,但是会影响到并发线程。
2.4 内存模型
java
内存模型操作内存的规则:
- 线程对共享变量的所有操作都必须在自己的工作内存(本地内存)中进行,不能直接从主内存中读写
- 同线程之间无法直接访问其他线程本地内存中的变量,线程间变量值的传递需要经过主内存来完成。
2.5 解决不可见问题
使用synchronized
加锁即可,实现可见性过程:
- 获得
mutex lock
- 清空本地内存
- 从主内存拷贝变量的最新副本到本地内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放锁
使用synchronized
会同时解决并发产生的三个问题
2.6 锁优化
synchronized
是重量级锁,效率不高,在jdk1.6
之后对它做了很多的优化。
在jdk1.6
引入了大量的优化,如自旋锁,适应性自旋锁,锁消除,锁粗化,偏向锁,轻量级锁。
锁的四种状态:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
3. 锁的优化
3.1 自旋锁
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。
自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在JDK 1.4.2中引入,默认关闭
如果自己调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。
3.2 适应性自旋锁
JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
3.3 锁消除
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。
JVM可以明显检测到变量没有逃逸时,可以将内部的加锁消除
3.4 锁粗化
下面有一个概念:
在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用
域中才进行同步,这样做的目的是为了使需要同步的操作量尽可能缩小,如果存在锁
竞争,那么等待锁的线程也能尽快拿到锁。
这个概念在大多数情况下都是对的,但是如果连续的加锁解锁操作,就会导致不必要的性能损耗。
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。JVM检测到对同一个对象连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到方法之外。
3.5 偏向锁
轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。而偏向锁只需要检查是否为偏向锁、锁标识为以及ThreadID即可,可以减少不必要的CAS操作。
3.6 轻量级锁
引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。轻量级锁主要使用CAS进行原子操作。
但是对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
3.7 重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock(互斥锁)实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。