深入浅出,对Java多线程的探索 - 笔者的一段学习笔记,如果错漏,恳请指教。
sychronized
可以保证多条语句在synchronized
块中语意上是原子的。由Java内存模型来直接保证的原子性变量操作包括read、load、use、assign、store和write六个,大致可以认为基础数据类型的访问和读写是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock与unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐匿地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块—synchronized关键字,因此在synchronized块之间的操作也具备原子性。x = 10;//1
y = x;//2
x++;//3
x = x + 1;//4
只有语句1是原子性操作 - 语句1直接将10赋值给x(将数值10写入工作内存);语句2包含两个原子性操作(读x值,将x写入内存),合起来就不是原子性操作了(因为可能中间会被打断,造成一个有效,一个无效的情况,语句3,4也是同理);语句3包含3个操作(读x值,加1,写入内存)
总结:只有简单的读取、赋值才是原子操作(必须是将具体数值赋值给某个变量,变量之间的赋值不是原子操作);Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围的原子性,需要通过synchronized和lock来实现(保证任意时刻只有一个线程执行对应代码块)
volatile关键字可保证可见性:当一个共享变量被volatile修饰时,它会保证修改的值会立即更新到主存,当有其他线程需要读取,它会去内存中读取新值。(当然,synchronized、Lock 也可以保证)
synchronized、lock通过保证线程同步,自然保证了有序性。另外,通过volatile关键字也可保证一定的“有序性”(具体原理稍候再描述)
注意:Java内存模型中,如果操作遵循“先行发生原理”(happens-before),则不需要通过任何手段就可以保证有序性。
先行发生原则(happens-before):
* 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
* 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
* volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
* 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
* 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
* 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
* 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
* 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
前四条主要规则解释:
- 第一条:在单线程中,程序执行看起来是有序的(因为在保证执行结果一致的前提下,虚拟机可能会对程序代码进行重排序 - 也就是仅对不存在数据依赖性的指令进行重排序)
- 第二条: 如果锁处于锁定状态,那么必须对锁进行释放,后面才能继续进行lock操作
- 第三条: 如果某线程写入一个变量,另外一个线程去读取,那么,写入操作必须要在读取操作之前
- 第四题: 显而易见的传递性
文章及书本推荐:
- 正确使用 Volatile 变量
- Volatile关键字解析
- 《深入理解 Java 虚拟机》- 周志明(著)
* 1. 保证了不同线程对该变量的可见性*
注意:volatile 变量在各个线程的工作内存中,可以存在不一致的情况,但由于每次使用都要先刷新,执行引擎看不到不一致的情况,因此可认为不存在一致性问题,但java中运算操作并不是原子性操作,导致volatile变量的运算在并发状态下一样是不安全的,下面尝试用代码说明。
public class LearningVolatile {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
// 等待所有累加线程结束
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
//每次运行结果不同,总为一个小于200000的数字
//笔者数次输出结果为:73000+
代码剖析:
如果正确并发,理论结果应为200000,显然,这段程序并没有正确并发,使用javap 命令反编译得到字节码。可以看到,race++ 一行代码包含:取值(getstatic-volatile保证,此时取race值的正确性)、将常量压入栈(iconst_1)、(将两个栈顶int值相加并压入栈顶)iadd,写入(putstatic),就如上面解析的一样,这一系列操作合起来,就不再符合原子性了,当取值之后,其他线程可能已经把race加大了,本线程的race值则变成过期数据,最后写入错误的race值到主内存中。
另:其实采用字节码来分析,也是欠缺严谨的,即使编译出来只有一条字节码,也并不代表该指令就是一个原子操作(因为一条字节码执行时候,解析器还是要运行多行代码才可以实现它的语义,使用 -XX:+PrintAssembly参数输出反汇编来分析会更加严谨),但这里字节码已经能够说明问题,可不必再深入细究。
//反编译字节码(increase方法)
public static void increase();
Code:
0: getstatic #2 // Field race:I
3: iconst_1
4: iadd
5: putstatic #2 // Field race:I
8: return
volatile 主要适用场景:
1. 运算结果不依赖变量当前值,能够确保只有单一线程修改变量的值
2. 变量不需要与其他的状态变量共同参与不变约束
* 2. 保证了不同线程对该变量的可见性*
关键需要理解:为何指令重排序会干扰程序的并发执行?
例子:初始化完成的标识,如果指令重排,可能会导致 initialized = true
提前执行,使B线程运行出现问题
A线程中
volatile boolean initialized = false;
XXX x = new XXX();//模拟初始化
...
initialized = true;//说明初始化完成
B线程中
while(!initialized){
sleep();
}
doSomethingWithAConfig();//接下来,就可以利用A中初始好的配置信息进行操作啦
主要的线程相关类:Thread类、Runnable接口、Callable接口、Future类
Thread类实现了Runnable接口,常用的Thread类相关方法:
start();//启动线程
yield();//让出CPU,让其他就绪状态(RUNNABLE)的线程运行
sleep();//停滞,使线程进入阻塞状态,但不能改变对象的机锁(仍持有对象锁,其他线程不可访问该对象),注意与wait()方法区分
wait();//等待,释放对象锁(其他线程可访问),因此必须要放到 synchronized 代码块中,否则会抛出“java.lang.IllegalMonitorStateException”异常,使用notify或者noyifyAll方法来唤醒当前等待池中的线程
join();//阻塞当前执行的线程,直到调用该方法的线程执行完毕才释放
interrupte();//检查当前线程是否被打断(返回boolean类型)
interrupted();//将中断状态标识置为true
注意:Thread的异常处理,需要在run方法中,使用try/catch来处理,另外有方法setUncaughtExceptionHandler来处理 uncheck exception
推荐通过实现Runnable接口,而非继承Thread,从而避免单继承的局限性。
Callable接口与Runnable接口相似,但是Runnable不会返回结果,并且无法抛出返回结果的异常,而Callable功能更强大一些,被线程执行后,可以返回值,这个返回值可以被Future拿到,基本使用方法:
//Future的两个方法
future.isDone() //return true,false 无阻塞
future.get() // return 返回值,阻塞直到该线程运行结束
// 方法1:FutureTask实现了两个接口,Runnable和Future,所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值
public static void main(String[] args) {
Callable callable = new Callable() {
public Integer call() throws Exception {
// 返回码
return new Random().nextInt(100);
}
};
FutureTask future = new FutureTask(callable);
new Thread(future).start();
try {
Thread.sleep(5000);// 模拟业务逻辑
System.out.println(future.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
// 方法2:通过ExecutorService(继承Executor,管理Thread,简化并发编程)的submit方法执行Callable
public static void main(String[] args) {
ExecutorService threadPool = Executors.newCachedThreadPool();
Future future = threadPool.submit(new Callable() {
public Integer call() throws Exception {
// 返回码
return new Random().nextInt(100);
}
});
try {
Thread.sleep(5000);// 模拟业务逻辑
System.out.println(future.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
锁与同步方法是常用于保证Java操作原子性,使用锁,可以保证同一时间只有一个线程拿到锁,因此保证了同一时间只有一个线程能够执行申请锁和释放锁之间的代码。
Java Lock 实现方式:Lock详解 - Raven’s Blog
//使用lock来实现synchronized的效果,主要区别:使用synchronized修饰的方法或代码块,在执行完之后会自动释放锁;而Lock则需要手动释放。
public class LockTest {
public static void main(String[] args) {
final Outputter1 output = new Outputter1();
new Thread() {
public void run() {
output.output("zhangsan");
};
}.start();
new Thread() {
public void run() {
output.output("lisi");
};
}.start();
}
}
class Outputter1 {
private Lock lock = new ReentrantLock();// 锁对象
public void output(String name) {
lock.lock();// 得到锁
try {
//互斥区
for(int i = 0; i < name.length(); i++) {
System.out.print(name.charAt(i));
}
} finally {
//为了保证能被释放,因此需要放在finall
lock.unlock();// 释放锁
}
}
}
相对synchronized,锁机制更具灵活性,例如,使用读写锁(ReadWriteLock - 读与写互斥、写与写互斥、但读与读不互斥,以此提高性能)
//读写锁
public class ReadWriteLockTest {
public static void main(String[] args) {
final Data data = new Data();
for (int i = 0; i < 3; i++) {
new Thread(new Runnable() {
public void run() {
for (int j = 0; j < 5; j++) {
data.set(new Random().nextInt(30));
}
}
}).start();
}
for (int i = 0; i < 3; i++) {
new Thread(new Runnable() {
public void run() {
for (int j = 0; j < 5; j++) {
data.get();
}
}
}).start();
}
}
}
class Data {
private int data;// 共享数据
private ReadWriteLock rwl = new ReentrantReadWriteLock();
public void set(int data) {
rwl.writeLock().lock();// 取到写锁
try {
System.out.println(Thread.currentThread().getName() + "准备写入数据");
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.data = data;
System.out.println(Thread.currentThread().getName() + "写入" + this.data);
} finally {
rwl.writeLock().unlock();// 释放写锁
}
}
public void get() {
rwl.readLock().lock();// 取到读锁
try {
System.out.println(Thread.currentThread().getName() + "准备读取数据");
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "读取" + this.data);
} finally {
rwl.readLock().unlock();// 释放读锁
}
}
}
Java并发包(java.util.concurrent.atomic)下,提供了原子操作类来实现原子性操作方法,从而保证原子性,而其本质是利用了CPU级别的CAS指令。(下面以AtomicInteger为例说明)
//源码中的两个有代表性的方法
//相当于原子性的++i
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
//相当于原子性的--i
public final int decrementAndGet() {
for (;;) {
int current = get();
int next = current - 1;
if (compareAndSet(current, next))
return next;
}
}
//两种方法都没有使用阻塞式方法来保证原子性,而是通过了CAS指令实现
探究线程知识过程中,发现其与许多其他知识或多或少有着联系,仍需继续努力,嗯,加油!
待扩展:Java类加载机制、JVM内存模型、Java异常分析