三、Java并发
本文最后更新于 2025-03-06,文章超过7天没更新,应该是已完结了~
线程的生命周期
初始(NEW):线程被构建,还没有调用start()
运行(RUNNABLE):包括操作系统的就绪和运行两种状态。
阻塞(BLOCKED):一般是被动的,在抢占资源时得不到资源,被动地挂起在内存,等待资源释放时将其唤醒。线程阻塞会释放CPU,但不会释放内存。
等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定的动作,比如通知或中断
超时等待(TIMED_WAITING):指定时间后自行返回。
终止(TERMINATED):表示该现场已经执行完毕。
创建线程的方式
//创建线程对象
Thread t= new Thread(){
public void run(){
//执行的任务...
}
};
//启动线程
t.start();
把线程和任务分开
Thread 代表线程
Runnable 可运行的任务
Runnable runnable = new Runnable(){
public void run(){
//要执行的任务
}
};
//创建线程对象
Thread t= new Thrad(runnable,"name");
//启动线程
t.start();
在jdk1.8后使用lambada精简代码
Runnable runnable=()-> log.info("执行的任务");
Thread t=new Thread(runnable,"name");
t.start();
Thead和Runnable的关系?
@FunctionalInterface //函数式编程 说明可以使用lambda表达式
public interface Runnable {
/**
* When an object implementing interface <code>Runnable</code> is used
* to create a thread, starting the thread causes the object's
* <code>run</code> method to be called in that separately executing
* thread.
* <p>
* The general contract of the method <code>run</code> is that it may
* take any action whatsoever.
*
* @see java.lang.Thread#run()
*/
public abstract void run();
}
public class Thread implements Runnable{...}
Thread是一个类,而Runnable是一个接口。
Thread类实现了Runnable接口,Runnable接口里只有一个抽象的run()方法。说明Runnable不具备多线程的特性。Runnable依赖Thread类的start方法创建一个子线程,再在这个子线程里调用run()方法,才能让Runnable接口具备多线程的特性。
FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况
// 创建任务对象
FutureTask<Integer> task3 = new FutureTask<>(() -> {
log.debug("hello");
return 100;
});
// 参数1 是任务对象; 参数2 是线程名字,推荐
new Thread(task3, "t3").start();
// 主线程阻塞,同步等待 task 执行完毕的结果
Integer result = task3.get();
log.debug("结果是:{}", result);
输出
19:22:27 [t3] c.ThreadStarter - hello
19:22:27 [main] c.ThreadStarter - 结果是:100
public class FutureTask<V> implements RunnableFuture<V> //FutureTask实现了
Runnable接口
@FunctionalInterface //函数式编程 属于lambda表达式
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
具体的lambda表达式使用在后面会讲。
public class ExecutorsTest {
public static void main(String[] args) {
//获取ExecutorService实例,生产禁用,需要手动创建线程池
ExecutorService executorService = Executors.newCachedThreadPool();
//提交任务
executorService.submit(new RunnableDemo());
}
}
class RunnableDemo implements Runnable {
@Override
public void run() {
System.out.println("zz");
}
}
查看进程线程的方法
windows
任务管理器可以查看进程和线程数,也可以用来杀死进程
tasklist 查看进程
taskkill 杀死进程
linux
ps -fe 查看所有进程
ps -fT -p <PID> 查看某个进程(PID)的所有线程
kill杀死进程
top 按大写 H 切换是否显示线程
top -H -p <PID> 查看某个进程(PID)的所有线程
Java
jps 命令查看所有 Java 进程
jstack <PID> 查看某个 Java 进程(PID)的所有线程状态
jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)
线程上下文切换(Thread Context Switch)
因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码
线程的 cpu 时间片用完
垃圾回收
有更高优先级的线程需要运行
线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念 就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。
状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等。
Context Switch 频繁发生会影响性能。
线程常用的方法
调用start与run的区别
当程序调用 start()方法,将会创建一个新线程去执行run()方法中的代码。run()就像一个普通方法一
样,直接调用 run()的话,不会创建新线程。
一个线程的 start() 方法只能调用一次,多次调用会抛出 java.lang.IllegalThreadStateException异常。
run()方法则没有限制。
调用sleep() 与 yield()的区别?
sleep ()
1. 调用 sleep 会让当前线程从 Running进入 Timed Waiting 状态(阻塞)
2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
3. 睡眠结束后的线程未必会立刻得到执行(还得获取时间片)
补充:建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性
yield ()
1. 调用 yield 会让当前线程从 Running 进入 Runnable就绪状态,然后调度执行其它线程。所以执行yield()方法的线程可能在进入可执行状态后马上又被执行。
2. 具体的实现依赖于操作系统的任务调度器
3.yield()会让优先级同级或优先级更高的 有更高的执行机会。而sleep()不会考虑线程优先级。
interrupt 方法详解
打断 sleep,wait,join 的线程
这几个方法都会让线程进入阻塞状态
打断 正在sleep 的线程(该线程打断睡眠抛出异常后会继续运行), 会清空打断状态,以 sleep 为例
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at java.lang.Thread.sleep(Thread.java:340)
at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
at cn.itcast.n2.util.Sleeper.sleep(Sleeper.java:8)
at cn.itcast.n4.TestInterrupt.lambda$test1$3(TestInterrupt.java:59)
at java.lang.Thread.run(Thread.java:745)
21:18:10.374 [main] c.TestInterrupt - 打断状态: false
sleep,wait,join被打断后,打断状态一定为 false,且抛出异常。
打断正常运行的线程
20:57:37.964 [t2] c.TestInterrupt - 打断状态: true
那么会将该线程的中断标志设置为 true,被设置中断标志的线程将继续正常运行,不受影响。
打断 park 线程
打断 park 线程, 不会清空打断状态
private static void test3() throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
}, "t1");
t1.start();
sleep(0.5);
t1.interrupt();
}
21:11:52.795 [t1] c.TestInterrupt - park...
21:11:53.295 [t1] c.TestInterrupt - unpark...
21:11:53.295 [t1] c.TestInterrupt - 打断状态:true
补充:如果已经打断过park线程,打断标记为true,那该线程再次park会失效。
private static void test4() {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
log.debug("park...");
LockSupport.park();
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
}
});
t1.start();
sleep(1);
t1.interrupt();
}
输出
21:13:48.783 [Thread-0] c.TestInterrupt - park...
----------------------------------------------------------
21:13:49.809 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.812 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.813 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.813 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.813 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
可以使用 Thread.interrupted() 清除打断标记
Park、 Unpark方法详解
它们是 LockSupport 类中的方法
// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)
Thread t1 = new Thread(() -> {
log.debug("start...");
sleep(2);
log.debug("park...");
LockSupport.park();
log.debug("resume...");
}, "t1");
t1.start();
log.debug("unpark...");
LockSupport.unpark(t1);
先执行unpark,再执行park,不了解原理的会很容易认为park住线程了,但并不会。
18:43:50.765 c.TestParkUnpark [t1] - start...
18:43:51.764 c.TestParkUnpark [main] - unpark...
18:43:52.769 c.TestParkUnpark [t1] - park...
18:43:52.769 c.TestParkUnpark [t1] - resume..
先讲一下LockSupport 的 park(底层用Unsafe类) 与 Object 的 wait & notify 相比
wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必
park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么【精确】
park & unpark 可以先 unpark,而 wait & notify 不能先 notify。
wait/notify详解
Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
BLOCKED 线程会在 Owner 线程释放锁时唤醒
WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争
常用API
obj.wait() 让进入 object 监视器的线程到 waitSet 等待 (放弃锁)
obj.notify() 在 object 上正在 waitSet 等待的线程中挑一个唤醒
obj.notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒
注意:它们都属于Object对象的方法。必须获得此对象的锁,才能调用这几个方法。
关键:waiting的线程被调用 notify()唤醒后仍需要 进入entrylist重新竞争
补充:sleep(long n) 和 wait(long n) 的区别
1) sleep 是 Thread 方法,而 wait 是 Object 的方法
2) sleep 不需要强制和 synchronized 配合使用,但 wait 需要 和 synchronized 一起用
3) sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁
4) 它们状态都是 TIMED_WAITING
wait/notify的 正确使用姿势
synchronized(lock) {
while(条件不成立) {
lock.wait();
}
// 干活
}
//另一个线程
synchronized(lock) {
lock.notifyAll();
}
park、unpark原理
每个线程都有自己的一个 Parker 对象,由三部分组成 counter,cond 和 _mutex 打个比喻。
线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
调用 park 就是要看需不需要停下来歇息
如果备用干粮耗尽,那么钻进帐篷歇息
如果备用干粮充足,那么不需停留,继续前进
调用 unpark,就好比令干粮充足
如果这时线程还在帐篷,就唤醒让他继续前进
如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮
1. 当前线程调用 Unsafe.park() 方法
2. 检查 counter ,本情况为 0,这时,获得 mutex 互斥锁
3. 线程进入 _cond 条件变量阻塞 Thread-0线程
4. 设置 _counter = 0
1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
2. 唤醒 cond 条件变量中的 Thread0
3. Thread_0 恢复运行
4. 设置 _counter 为 0
调用sleep() 与 wait()的区别?
相同点:
它们都可以使当前线程暂停运行,把机会交给其它线程
任何线程在调用wait() 和 sleep() 之后,在等待期间被打断都会抛出 InterruptedException
不同点:
wait() 是Object超类中的方法,而sleep()是线程Thread类中的方法
wait() 会释放锁,而sleep() 不会释放锁
唤醒方式不同。wait() 依靠notify 或者notifyAll、中断、达到指定时间来唤醒;而sleep() 到达指定时间唤醒
调用wait() 需要先获取对象的锁,而Thread.sleep()不用
volatile底层原理
共享变量导致的问题?
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter++;
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter--;
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}",counter);
}
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析
例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存(元空间)和私有内存中进行数据交换:
这样导致线程上下文切换时,另一个线程读取的时主内存的数据,而不是另外一个线程处理后的数据。
volatile是轻量级的同步机制,volatile保证变量对所有的线程可见性,但是不保证原子性。
当对volatile变量进行写操作时,JVM会向处理器发送一条LOCK前缀的指令,目的是将该变量所在缓存行的数据写回系统内存。
由于缓存一致性协议,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,所以当处理器对这个数据进行修改操作的时候,会重新从系统内存中将数据读到处理器的缓存行。
volatile 关键字的两个作用:
保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,该变量的新值对其它线程是立即可见的。
禁止进行指令重排序,即有序性。
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
内存屏障两大功能
可见性
Java通过几种原子操作完成工作内存和主内存的交互:
lock:作用于主内存,把变量标识为线程独占状态。
unlock:作用于主内存,解除独占状态。
read:作用主内存,把一个变量的值从主内存传输到线程的工作内存。
load:作用于工作内存,把read操作传过来的变量值放入工作内存的变量副本中。
use:作用工作内存,把工作内存当中的一个变量值传给执行引擎。
assign:作用工作内存,把一个从执行引擎接收到的值赋值给工作内存的变量。
store:作用于工作内存的变量,把工作内存的一个变量的值传送到主内存中。
write:作用于主内存的变量,把store操作传来的变量的值放入主内存的变量中。
volatile的特殊规则就是:
read、load、use动作必须连续出现;assign、store、write动作必须连续出现。
所以,使用volatile变量能够保证:
每次读取前必须先从主内存刷新最新的值。
每次写入后必须立即同步回主内存当中。
有序性
在JVM中提供了四类内存屏障指令:
下面是基于保守策略的JMM内存屏障插入策略。
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的前面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障
那么该怎么理解这个插入策略?即在执行到内存屏障这句指令时,在它前面的操作已经全部完成后,后面的操作才可以进行。
但是要注意,volatile不能解决指令交错问题 !(也就是说不能解决多线程并发执行代码的顺序)
举个例子
x = 2; //语句1,x、y为非volatile变量
y = 0; //语句2
flag = true; //语句3,flag为volatile变量
x = 4; //语句4
y = -1; //语句5
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
虽然synchronized锁住的代码块有可能有指令重排序,但由于是单线程执行,所以对执行结果没影响,并且单线程是原子性的。
指令重排序是什么?
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;
j = ...;
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是
i = ...;
j = ...;
也可以是
j = ...;
i = ...;
这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。为什么要有重排指令这项优化呢?从 CPU执行指令的原理来理解一下吧
指令重排序优化
每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据 - 写回 ,这 5 个阶段。
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行。
但指令重排的前提是,重排指令不能影响结果,例如
// 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );
// 不能重排的例子(有指令依赖的不可能重排序)
int a = 10; // 指令1
int b = a - 5; // 指令2
现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令的吞吐率。
诡异的结果
public class test {
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(Result r) {
num = 2;
ready = true;
}
class Result{
private int r1;
}
}
有同学这么分析
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
但结果还有可能是 0 !
这种情况下是:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2 。(指令交错且刚好重排序会导致指令交错执行的结果是错误的)
这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化。这个现象需要通过大量测试才能复现。
synchnorized可见性原因
1)线程解锁前,必须把共享变量的最新值刷新到主内存中
2)线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值
synchronized的用法有哪些?
//成员方法锁住当前实例对象
class Test{
public synchronized void test() {
}
}
等价于
class Test{
public void test() {
synchronized(this) {
....
}
}
}
//静态方法锁住类对象
class Test{
public synchronized static void test() {
}
}
等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
注意:类对象不包含当前实例对象
synchronized(变量、当前实例对象、类对象) {
// 操作
}
volatile和synchronized的区别是?
volatile只能用在变量上,而synchronized可以用在 类,变量,方法和代码块上
volatile能保证可见性,synchronized保证原子性与可见性
volatile 禁用指令重排序,synchronized不会
volatile不会造成堵塞,synchronized会
变量的线程安全分析
成员变量和静态变量是否线程安全:
如果它们没有共享,则线程安全
如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
如果只有读操作,则线程安全
如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是否线程安全:
一般情况下局部变量是线程安全的
但局部变量引用的对象则未必:
如果对象仅在方法内创建、使用、消亡,则是线程安全的;
如果一个对象由外部传入,或者传出外部(对象逃离方法外)则需要考虑线程安全问题
public static void main(String[] args) throws InterruptedException {
Number number = new Number();
number.method1(3);
}
static class Number{
public void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
System.out.println("执行完后的list:"+list);
method3(list);
System.out.println("执行完后的list:"+list);
}
}
private void method2(ArrayList<String> list) {
list.add("1");
}
void method3(ArrayList<String> list) {
new Thread(()->{
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
list.add("1");
System.out.println("另外一个线程捣乱后的结果:"+list);
}).start();
list.remove(0);
}
}
本来最终结果list对象应该为空,但是list对象逃出方法体,多线程情况下出现安全问题,所以导致最终结果list不为空。
父类方法使用private 或 final 提供【安全】的意义所在
父类中被private修饰的方法表示仅在该类可见,所以子类没有继承到父类的private方法,因此,若子类定义了一个与父类的private方法相同的方法名和参数列表也是没问题的,相当于子类自己定义了一个新的方法,不能覆盖;
父类中final修饰的方法,不能覆盖,但可继承。说明这种方法提供的功能已经满足当前要求,不需要进行扩展,并且也不允许任何从此类继承的类来重写这种方法,但是继承仍然可以继承这个方法,也就是说可以直接使用
class Fu {
int i = 2;
public int getI() {
return i;
}
}
class Zi extends Fu {
int i = 4;
}
public class Jicheng {
public static void main(String[] args) {
System.out.println(new Zi().getI());
}
}
输出结果为2?
子类不是应该 也继承了父类的getI()方法么,
怎么get到的是父类的值?
JAVA规定,变量前面没有特别说明是谁的变量,那么就适用"就近原则",显然父类对象的属性int i是最近的
子类继承父类,会继承父类的所有属性(properties)和方法(methods),包括private修饰的属性和方法,但是子类只能访问和使用非private的
new Zi()就是创建一个子类对象,而子类对象内部包涵了父类对象,所以又要先new Fu(), 也就是说创建子类对象 = 创建父类对象 + 其他
子类对象没有重写(Overriding)父类的方法,那么这个方法就还"包涵"在父类对象里,子类对象用getI()方法,其实质调用的是 子类对象"肚子里的"那个父类对象的方法。
synchronized的底层实现原理?
synchronized同步代码块是通过 monitorenter 和 monitorexit 指令,其中monitorenter 指令指向同步代码块的开始位置,monitorexit 指向同步代码块结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取monitor的持有权(monitor对象存在于每个Java对象的对象头中,synchronized锁便是通过这种方式获取锁的,也就是为什么Java中任意对象可以作为锁的原因)
其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行monitorexit指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
从编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的(当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。)
两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
对象头和Monitor 概念
对象的内存布局
public class Customer{
int id = 1001;
String name;
Account acct;
{
name = "匿名客户";
}
public Customer() {
acct = new Account();
}
}
public class CustomerTest{
public static void main(string[] args){
Customer cust=new Customer();
}
}
图示
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
说明:若与Monitor关联成功则 Mark Word变为62位指针(指向 Monitor)+2 位00/10,即Mark Word记录Monitor对象的起始地址。
Monitor 结构如下:
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象从出生就自带有一把看不见的锁,称为内部锁或者Monitor锁。
刚开始 Monitor 中 Owner 为 null
当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一 个 Owner
在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED
Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的
图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足(比如生产者发现队列已满)进入 WAITING 状态。
public class test {
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
}
对应的字节码
说明:如果6-16行执行没有发生异常,那么直接到24,即return。
如果发生异常,则到19行,进行异常处理,注意的是异常后还是会主动将锁释放
无锁 vs 偏向锁 vs 轻量级锁 vs 重量级锁
无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
学会分析对象头
从图中可以看到,对象头所占用的内存大小为16*8bit=128bit。如果大家自己动手去打印输出,可能得到的结果是96bit,这是因为我关闭了指针压缩。jdk8版本是默认开启指针压缩的,可以通过配置vm参数关闭指针压缩:-XX:-UseCompressedOops
现在取消关闭指针压缩的配置,开启指针压缩之后,再看User对象的内存布局。
对象头的前64位是MarkWord,后32位是类的元数据指针(开启指针压缩)。
从该对象头中分析加锁信息,MarkWordk为0x0000700009b96910,二进制为00000000 00000000 01110000 00000000 00001001 10111001 01101001 00010000(大端存储Big-Endian:高位字节存放在内存的低地址端,低位字节存放在内存的高地址端)
偏向锁的撤销
// 添加虚拟机参数 -XX:BiasedLockingStartupDelay=0
public class test {
public static void main(String[] args) {
Cat cat = new Cat();
ClassLayout classLayout = ClassLayout.parseInstance(cat);
new Thread(() -> {
System.out.println("synchronized 前");
System.out.println(classLayout.toPrintable());
synchronized (cat) {
System.out.println("synchronized 中");
System.out.println(classLayout.toPrintable());
}
System.out.println("synchronized 后");
System.out.println(classLayout.toPrintable());
}, "t1").start();
}
}
public class Cat {
}
我这也没使用synchronized关键字呀,那不也应该是无锁么?怎么会是偏向锁呢?你会发现占用 thread 和 epoch 的 位置的均为0,说明当前偏向锁并没有偏向任何线程。此时这个偏向锁正处于可偏向状态,准备好进行偏向了!你也可以理解为此时的偏向锁是一个特殊状态的无锁。
当使用了synchronized关键字:
对象头内容有了明显的变化,当前偏向锁偏向主线程。
当退出了synchronized关键字代码块,偏向锁还是锁着主线程,说明线程不会主动释放偏向锁的。
但有些情况线程会主动释放偏向锁,现在讲下偏向锁的撤销。
偏向锁的撤销
撤销 - 调用对象 hashCode
当一个对象当前正处于偏向锁状态,并且需要计算其identity hash code的话,则它的偏向锁会被撤销
请一定要注意,这里讨论的hash code都只针对identity hash code。用户自定义的hashCode()方法所返回的值跟这里讨论的不是一回事。
还是上面的代码
{
...
cat.hashCode();
System.out.println("synchronized 后");
System.out.println(classLayout.toPrintable());
}, "t1").start();
}
线程主动释放偏向锁变无锁状态了
撤销 - 其它线程锁住同一个对象
public class test {
public static void main(String[] args) {
Cat cat = new Cat();
ClassLayout classLayout = ClassLayout.parseInstance(cat);
Thread t2 = new Thread(()->{
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (cat){
System.out.println("我来锁住cat对象了");
}
});
t2.start();
new Thread(() -> {
System.out.println("synchronized 前");
System.out.println(classLayout.toPrintable());
synchronized (cat) {
System.out.println("synchronized 中");
System.out.println(classLayout.toPrintable());
}
try {
t2.join(); //等t2锁住对象后再执行
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("等t2 线程 synchronized 后,t1线程的偏向锁状态:");
System.out.println(classLayout.toPrintable());
}, "t1").start();
}
}
前中还是跟之前一样
批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的Thread ID 。当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
public class test {
public static void main(String[] args) {
Vector<Dog> list = new Vector<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 30; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
System.out.println(ClassLayout.parseInstance(d).toPrintable());
}
}
synchronized (list) {
list.notify(); //防止t2线程对list集合进行影响
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (list) {
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
for (int i = 0; i < 30; i++) {
if(i==19){
System.out.println("---------------------开始变化,剩下的偏向锁都指向t2线程---------------------");
}
Dog d = list.get(i);
System.out.println("t2线程加锁前: "+ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
System.out.println("t2线程加锁后: "+ClassLayout.parseInstance(d).toPrintable());
}
}
}, "t2");
t2.start();
}
}
t1线程将30个dog对象都加上偏向锁--->指向t1线程
当t2线程将前20个dog对象加锁时,t1线程放弃偏向锁,偏向锁变为轻量级锁指向t2线程
当t2线程将后20个dog对象加锁时,原本指向t1线程的偏向锁,指向了t2
批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象 都会变为不可偏向的,新建的对象也是不可偏向的。
锁消除
锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。也即如果逃逸分析发现对象是非逃逸的,编译器就可以自行消除同步。
轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间(在栈帧中属于附加信息),用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位从“01”设置为“00”,表示此对象处于轻量级锁定状态。
Lock Record概念
Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word),表示该锁被这个线程占用/或者锁住这个对象。
将Mark Word(记录Monitor的地址)拷贝到Lock Record的HashCode字段中,将Mark Word替换为指向Lock Record地址的指针,这样被锁住的对象就会和Lock Record有关联,最后还要将Lock Record的Owner字段存放对象的Mark Word字段的值。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
整体的锁状态升级流程如下:
与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
消费队列可以用来平衡生产和消费的线程资源
生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
JDK 中各种阻塞队列,采用的就是这种模式
ReentrantLock与synchronized区别?
使用synchronized关键字实现同步,线程执行完同步代码块会自动释放锁,而ReentrantLock需要手动释放锁。
ReentrantLock上等待获取锁的线程是可中断的,线程可以放弃等待锁。而synchronized会无限等待下去。
ReentrantLock可以设置超时时间。在指定的截止时间之前获取锁,如果截止时间到了还没有获取到锁,则返回。
synchronized是非公平锁,ReentrantLock可以设置为公平锁。
ReentrantLock的tryLock()方法可以尝试获取非阻塞得获取锁,调用该方法后立即返回,如果能够获取则返回true,否则则返回false。
支持多个条件变量 。可叫醒 因某条件处于waiting set的线程,不同synchronized叫醒全部处于waitig set线程。
与 synchronized 一样,都支持可重入(重复获得锁,如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住)
ReentrantLock的实践
// 获取锁
reentrantLock.lock();
try {
// 临界区
} finally {
// 释放锁
reentrantLock.unlock();
}
可打断(被动)
@Slf4j
public class test {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
log.debug("启动...");
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
e.printStackTrace();
log.debug("等锁的过程中被打断");
return;
}
try {
log.debug("获得了锁");
} finally {
lock.unlock();
}
}, "t1");
lock.lock(); //main线程获取锁
log.debug("获得了锁");
t1.start();
try {
sleep(1);
t1.interrupt();
log.debug("执行打断");
} finally {
lock.unlock();
}
}
结果:
18:02:40.520 [main] c.TestInterrupt - 获得了锁
18:02:40.524 [t1] c.TestInterrupt - 启动...
18:02:41.530 [main] c.TestInterrupt - 执行打断
java.lang.InterruptedException
at
java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchr
onizer.java:898)
at
java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchron
izer.java:1222)
at java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335)
at cn.itcast.n4.reentrant.TestInterrupt.lambda$main$0(TestInterrupt.java:17)
at java.lang.Thread.run(Thread.java:748)
18:02:41.532 [t1] c.TestInterrupt - 等锁的过程中被打断
注意,这里的ReentrantLock设置的是可中断锁 lock.lockInterruptibly()
锁超时(主动)
立刻失败:
....
Thread t1 = new Thread(() -> {
log.debug("启动...");
if (!lock.tryLock()) {
log.debug("获取立刻失败,返回");
return;
}
try {
log.debug("获得了锁");
} finally {
lock.unlock();
}
}, "t1");
....
线程尝试获取锁,失败则立刻返回。lock.tryLock()
还可以设置超时失败,在tryLock()上设置参数(单位+时间)
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
公平锁
ReentrantLock 默认是不公平的,每个线程抢占锁的顺序不定,谁运气好,谁就获取到锁,和调用lock方法的先后顺序无关。
公平锁一般没有必要,会降低并发度。
ThreadLocal介绍
从Java官方文档中的描述:ThreadLocal类用来提供线程内部的局部变量。这种变量在多线程环境下访问(通过get和set方法访问)时能保证各个线程的变量相对独立于其他线程内的变量。ThreadLocal实例通常来说都是private static类型的,用于关联线程和线程上下文。
使用案例
public class MyDemo {
private String content;
private String getContent() {
return content;
}
private void setContent(String content) {
this.content = content;
}
public static void main(String[] args) {
MyDemo demo = new MyDemo();
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
demo.setContent(Thread.currentThread().getName() + "的数据");
System.out.println("-----------------------");
System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
}
});
thread.setName("线程" + i);
thread.start();
}
}
}
结果:
从结果可以看出多个线程在访问同一个变量的时候出现的异常,线程间的数据没有隔离。下面我们来看下采用 ThreadLocal 的方式来解决这个问题的例子。
public class MyDemo1 {
private static ThreadLocal<String> tl = new ThreadLocal<>();
private String content;
private String getContent() {
return tl.get();
}
private void setContent(String content) {
tl.set(content);
}
public static void main(String[] args) {
MyDemo demo = new MyDemo();
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
demo.setContent(Thread.currentThread().getName() + "的数据");
System.out.println("-----------------------");
System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
}
});
thread.setName("线程" + i);
thread.start();
}
}
}
结果:
ThreadLocal与synchronized的区别
虽然ThreadLocal模式与synchronized关键字都用于处理多线程并发访问变量的问题, 不过两者处理问题的角度和思路不同。
在刚刚的案例中,虽然使用ThreadLocal和synchronized都能解决问题,但是使用ThreadLocal更为合适,因为这样可以使程序拥有更高的并发性。
ThreadLocl原理
ThreadLocalMap类的定义是在ThreadLocal类中,真正的引用却是在Thread类中。每个线程都有一个 ThreadLocalMap ,Map中元素的key为ThreadLocal类的实例对象 ,而值为对应线程设置的变量副本。
ThreadLocalMap 的 键为ThreadLocal 对象,因为每个线程中可有多个threadLocal变量,如longLocal和StringLocal。
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
Thread类中有一个成员变量属于ThreadLocalMap类(一个定义在ThreadLocal类中的内部类),它是一个Map,他的key是ThreadLocal实例对象。
当为ThreadLocal类的对象set值时,首先获得当前线程的ThreadLocalMap类属性,然后以ThreadLocal类的对象为key,设定value。get值时则类似。
ThreadLocal变量的活动范围为某线程,是该线程“专有的,独自霸占”的,对该变量的所有操作均由该线程完成!也就是说,ThreadLocal 不是用来解决共享对象的多线程访问的竞争问题的,因为ThreadLocal.set() 到线程中的对象是该线程自己使用的对象,其他线程是不需要访问的,也访问不到的。当线程终止后,这些值会作为垃圾回收。
由ThreadLocal的工作原理决定了:每个线程独自拥有一个变量,并非是共享的。
public T get() {
// 获取当前线程对象
Thread t = Thread.currentThread();
// 获取此线程对象中维护的ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
// 如果此map存在
if (map != null) {
// 以当前的ThreadLocal 为 key,调用getEntry获取对应的存储实体e
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
/*
初始化 : 有两种情况有执行当前代码
第一种情况: map不存在,表示此线程没有维护的ThreadLocalMap对象
第二种情况: map存在, 但是没有与当前ThreadLocal关联的entry
*/
return setInitialValue();
}
/**
* 初始化
*
* @return the initial value 初始化后的值
*/
private T setInitialValue() {
// 调用initialValue获取初始化的值
// 此方法可以被子类重写, 如果不重写默认返回null
T value = initialValue();
// 获取当前线程对象
Thread t = Thread.currentThread();
// 获取此线程对象中维护的ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
// 判断map是否存在
if (map != null)
// 存在则调用map.set设置此实体entry
map.set(this, value);
else
// 1)当前线程Thread 不存在ThreadLocalMap对象
// 2)则调用createMap进行ThreadLocalMap对象的初始化
// 3)并将 t(当前线程)和value(t对应的值)作为第一个entry存放至ThreadLocalMap中
createMap(t, value);
// 返回设置的值value
return value;
}
/**
* 返回当前线程对应的ThreadLocal的初始值
* 此方法的第一次调用发生在,当线程通过get方法访问此线程的ThreadLocal值时
* 除非线程先调用了set方法,在这种情况下,initialValue 才不会被这个线程调用。
* 通常情况下,每个线程最多调用一次这个方法。
*
* <p>这个方法仅仅简单的返回null {@code null};
* 如果程序员想ThreadLocal线程局部变量有一个除null以外的初始值,
* 必须通过子类继承{@code ThreadLocal} 的方式去重写此方法
* 通常, 可以通过匿名内部类的方式实现
*
* @return 当前ThreadLocal的初始值
*/
protected T initialValue() {
return null;
}
ThreadLocal内存泄漏的原因?
比较以上两种情况,我们就会发现,内存泄漏的发生跟ThreadLocalMap中的key是否使用弱引用是没有关系的。那么内存泄漏的的真正原因是什么呢?
细心的同学会发现,在以上两种内存泄漏的情况中,都有两个前提:
没有手动删除这个Entry
CurrentThread依然运行
第一点很好理解,只要在使用完ThreadLocal,调用其remove方法删除对应的Entry,就能避免内存泄漏。
第二点稍微复杂一点, 由于ThreadLocalMap是Thread的一个属性,被当前线程所引用,所以它的生命周期跟Thread一样长。那么在使用完ThreadLocal之后,如果当前Thread也随之执行结束,ThreadLocalMap自然也会被gc回收,从根源上避免了内存泄漏。
为什么使用弱引用
根据刚才的分析, 我们知道了: 无论ThreadLocalMap中的key使用哪种类型引用都无法完全避免内存泄漏,跟使用弱引用没有关系。
要避免内存泄漏有两种方式:
使用完ThreadLocal,调用其remove方法删除对应的Entry;使用完ThreadLocal,当前Thread也随之运行结束
相对第一种方式,第二种方式显然更不好控制,特别是使用线程池的时候,线程结束是不会销毁的。
也就是说,只要记得在使用完ThreadLocal及时的调用remove,无论key是强引用还是弱引用都不会有问题。那么为什么key要用弱引用呢?
事实上,在ThreadLocalMap中的set/getEntry方法中,会对key为null(也即是ThreadLocal为null)进行判断,如果为null的话,那么是会对value置为null的。
这就意味着使用完ThreadLocal,CurrentThread依然运行的前提下,就算忘记调用remove方法,弱引用比强引用可以多一层保障:弱引用的ThreadLocal会被回收,对应的value在下一次ThreadLocalMap调用set,get,remove中的任一方法的时候会被清除,从而避免内存泄漏。
AQS原理
AQS,AbstractQueuedSynchronizer ,抽象队列同步器,定义了一套多线程访问共享资源的同步器框架,
许多并发工具的实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch。
AQS使用一个 volatile 的int类型的成员变量state来表示同步状态,通过CAS修改同步状态的值。当线
程调用lock方法时,如果state =0,说明没有任何线程占有共享资源的锁,可以获得锁并将state加
1。如果 state不为0,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
private volatile int state;//共享变量,使用volatile修饰保证线程可见性
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败
时,同步器会将当前线程以及等待状态(独占或共享)构造成为一个节点(Node)并将其加入同步队列
并进行自旋,当同步状态释放时,会把首节点中的后继节点对应的线程唤醒,使其再次尝试获取同步状
态。
什么是CAS?
CAS全称Compare And Swap ,比较与交换,是乐观锁的主要实现方式。CAS在不使用锁的情况下实现多线
程之间的变量同步。ReentrantLock内部的AQS和原子类内部都使用了CAS。
CAS算法涉及到三个操作数:
需要读写的内存值V。
进行比较的值A。
要写入的新值B。
只有当V的值等于A时,才会使用原子方式用新值B来更新V的值,否则会继续重试直到成功更新值。
以AtomicInteger 为例,AtomicInteger getAndIncrement()方法底层就是CAS实现,关键代码是
compareAndSwapInt(obj, offset, expect, update) ,其含义就是,如果obj内的value和expect相
等,就证明没有其他线程改变过这个变量,那么就更新它为update,如果不相等,那就会继续重试直到
成功更新值。
原子类
基本类型原子类
Atomiclnteger:整型原子类
AtomicLong:长整型原子类
AtomicBoolean :布尔型原子类
Atomiclnteger类常用方法:
public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
Atomiclnteger 类主要利用 CAS (compare and swap) 保证原子操作,从而避免加锁的高开销。
数组类型原子类
AtomiclntegerArray:整形数组原子类
AtomicLongArray:长整形数组原子类
AtomicReferenceArray :引用类型数组原子类
AtomiclntegerArray类常用方法:
public final int get(int i) //获取 index=i 位置元素的值
public final int getAndSet(int i, int newValue)//返回 index=i 位置的当前的值,并将其设置为新值:newValue
public final int getAndIncrement(int i)//获取 index=i 位置元素的值,并让该位置的元素自增
public final int getAndDecrement(int i) //获取 index=i 位置元素的值,并让该位置的元素自减
public final int getAndAdd(int i, int delta) //获取 index=i 位置元素的值,并加上预期的值
boolean compareAndSet(int i, int expect, int update) //如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update)
public final void lazySet(int i, int newValue)//最终 将index=i 位置的元素设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
引用类型原子类
AtomicReference: 引用类型原子类
AtomicStampedReference:带有版本号的引用类型原子类。该类将整数值与引用关联起来,可用于解
决原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题。
AtomicMarkableReference :原子更新带有标记的引用类型。该类将boolean标记与引用关联起来
public class Test1 {
//指定版本号
static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
public static void main(String[] args) {
new Thread(() -> {
String pre = ref.getReference();
//获得版本号
int stamp = ref.getStamp(); // 此时的版本号还是第一次获取的
System.out.println("change");
try {
other();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//把ref中的A改为C,并比对版本号,如果版本号相同,就执行替换,并让版本号+1
System.out.println("change A->C stamp " + stamp + ref.compareAndSet(pre, "C", stamp, stamp + 1));
}).start();
}
static void other() throws InterruptedException {
new Thread(() -> {
int stamp = ref.getStamp();
System.out.println("change A->B stamp " + stamp + ref.compareAndSet(ref.getReference(), "B", stamp, stamp + 1));
}).start();
Thread.sleep(500);
new Thread(() -> {
int stamp = ref.getStamp();
System.out.println("change B->A stamp " + stamp + ref.compareAndSet(ref.getReference(), "A", stamp, stamp + 1));
}).start();
}
}
AtomicStampedReference和AtomicMarkableReference两者的区别
AtomicStampedReference 需要我们传入 整型变量 作为版本号,来判定是否被更改过
AtomicMarkableReference需要我们传入布尔变量 作为标记,来判断是否被更改过
CAS的问题
ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如
果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是
实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号
加一,这样变化过程就从A-B-A变成了1A-2B-3A
JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,原子更新带有版本号的引用类型。
循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个
共享变量操作时, CAS是无法保证操作的原子性的。
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原了性,可以把多个变量放在一个
对象里来进行CAS操作。
怎么保证线程安全
Java保证线程安全的方式有很多,其中较为常用的有三种,按照资源占用情况由轻到重排列,这三种保证线程安全的方式分别是原子类、volatile、锁。
JDK从1.5开始提供了java.util.concurrent.atomic包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。在atomic包里一共提供了17个类,按功能可以归纳为4种类型的原子更新方式,分别是原子更新基本类型、原子更新引用类型、原子更新属性、原子更新数组。无论原子更新哪种类型,都要遵循“比较和替换”规则,即比较要更新的值是否等于期望值,如果是则更新,如果不是则失败。
volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”,从而可以保证单个变量读写时的线程安全。可见性问题是由处理器核心的缓存导致的,每个核心均有各自的缓存,而这些缓存均要与内存进行同步。volatile具有如下的内存语义:当写一个volatile变量时,该线程本地内存中的共享变量的值会被立刻刷新到主内存;当读一个volatile变量时,该线程本地内存会被置为无效,迫使线程直接从主内存中读取共享变量。
原子类和volatile只能保证单个共享变量的线程安全,锁则可以保证临界区内的多个共享变量的线程安全,Java中加锁的方式有两种,分别是synchronized关键字和Lock接口。synchronized是比较早期的API,在设计之初没有考虑到超时机制、非阻塞形式,以及多个条件变量。若想通过升级的方式让它支持这些相对复杂的功能,则需要大改它的语法结构,不利于兼容旧代码。因此,JDK的开发团队在1.5新增了Lock接口,并通过Lock支持了上述的功能,即:支持响应中断、支持超时机制、支持以非阻塞的方式获取锁、支持多个条件变量(阻塞队列)。
实现线程安全的方式有很多,除了上述三种方式之外,还有如下几种方式:
1. 无状态设计 线程安全问题是由多线程并发修改共享变量引起的,如果在并发环境中没有设计共享变量,则自然就不会出现线程安全问题了。这种代码实现可以称作“无状态实现”,所谓状态就是指共享变量。 2. 不可变设计 如果在并发环境中不得不设计共享变量,则应该优先考虑共享变量是否为只读的,如果是只读场景就可以将共享变量设计为不可变的,这样自然也不会出现线程安全问题了。具体来说,就是在变量前加final修饰符,使其不可被修改,如果变量是引用类型,则将其设计为不可变类型(参考String类)。
3. 并发工具 java.util.concurrent包提供了几个有用的并发工具类,一样可以保证线程安全:
- Semaphore:就是信号量,可以控制同时访问特定资源的线程数量。
- CountDownLatch:允许一个或多个线程等待其他线程完成操作。
- CyclicBarrier:让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,所有被屏障拦截的线程才会继续运行。
4. 本地存储 我们也可以考虑使用ThreadLocal存储变量,ThreadLocal可以很方便地为每一个线程单独存一份数据,也就是将需要并发访问的资源复制成多份。这样一来,就可以避免多线程访问共享变量了,它们访问的是自己独占的资源,它从根本上隔离了多个线程之间的数据共享。
对线程池的理解
线程池可以有效地管理线程:它可以管理线程的数量,可以避免无节制的创建线程,导致超出系统负荷直至崩溃。它还可以让线程复用,可以大大地减少创建和销毁线程所带来的开销。 线程池需要依赖一些参数来控制任务的执行流程,其中最重要的参数有:corePoolSize(核心线程数)、workQueue(等待队列)、maxinumPoolSize(最大线程数)、handler(拒绝策略)、keepAliveTime(空闲线程存活时间)。当我们向线程池提交一个任务之后,线程池按照如下步骤处理这个任务: 1. 判断线程数是否达到corePoolSize,若没有则新建线程执行该任务,否则进入下一步。 2. 判断等待队列是否已满,若没有则将任务放入等待队列,否则进入下一步。 3. 判断线程数是否达到maxinumPoolSize,如果没有则新建线程执行任务,否则进入下一步。 4. 采用初始化线程池时指定的拒绝策略,拒绝执行该任务。 5. 新建的线程处理完当前任务后,不会立刻关闭,而是继续处理等待队列中的任务。如果线程的空闲时间达到了keepAliveTime,则线程池会销毁一部分线程,将线程数量收缩至corePoolSize。 第2步中的队列可以有界也可以无界。若指定了无界的队列,则线程池永远无法进入第3步,相当于废弃了maxinumPoolSize参数。这种用法是十分危险的,如果任务在队列中产生大量的堆积,就很容易造成内存溢出。JDK为我们提供了一个名为Executors的线程池的创建工具,该工具创建出来的就是带有无界队列的线程池,所以一般在工作中我们是不建议使用这个类来创建线程池的。 第4步中的拒绝策略主要有4个:让调用者自己执行任务、直接抛出异常、丢弃任务不做任何处理、删除队列中最老的任务并把当前任务加入队列。这4个拒绝策略分别对应着RejectedExecutionHandler接口的4个实现类,我们也可以基于这个接口实现自己的拒绝策略。 在Java中,线程池的实际类型为ThreadPoolExecutor,它提供了线程池的常规用法。该类还有一个子类,名为ScheduledThreadPoolExecutor,它对定时任务提供了支持。在子类中,我们可以周期性地重复执行某个任务,也可以延迟若干时间再执行某个任务。
- 感谢你赐予我前进的力量