一、 并发编程中的三个问题
(注:本博文中多线程编程使用了Lambda表达式来编写,如果读者对于Lambda表达式不了解,可以看博主的这篇博文Lambda表达式)
1.可见性
可见性(Visibility):是指一个线程对共享变量进行修改,另一个先立即得到修改后的最新值。
案例演示:一个线程根据boolean类型的标记flag, while循环,另一个线程改变这个flag变量的值,另一个线程并不会停止循环。
public class SeeOkDemo {
public static void main(String[] args) throws InterruptedException {
// 共享资源类
DataSource dataSource = new DataSource();
// 第一个线程
new Thread(() -> {
// 在第二个线程修改资源类的内容后,第一个线程不能感知到
while (dataSource.flag) {
// 只要flag是true,那么这个线程就一直在这里转
}
System.out.println("2s后~~");
}).start();
// 主线程休眠2秒
Thread.sleep(2000);
// 第二个线程,修改资源类的内容
new Thread(() -> {
dataSource.changeFlagToFalse();
}).start();
}
}
//资源类
class DataSource {
boolean flag = true;
// 对flag属性进行修改
public void changeFlagToFalse() {
this.flag = false;
System.out.println(Thread.currentThread().getName() + "\t 我把falg修改为flase~~");
}
}
运行结果:程序一直在线程1的while循环中,可见在多线程并发编程时,会出现可见性问题,当一个线程对共享变量进行了修改,另外的线程并没有立即看到修改后的最新值。
2.原子性
原子性(Atomicity):在一次或多次操作中,要么所有的操作都执行并且不会受其他因素干扰而中断,要么所有的操作都不执行。
案例演示:5个线程各执行1000次 i++;
public class AutomicDemo {
static int number = 0;
public static void main(String[] args) throws InterruptedException {
// 共享资源类
DataSource2 source = new DataSource2();
//开启5个线程同时执行++的操作
for (int i = 1; i <= 5; i++) {
new Thread(() -> {
source.numberAdd1000();
}, String.valueOf(i)).start();
}
// 当除了主线程后台的GC线程之外,还有其他子线程,那么主线程yield
while (Thread.activeCount() > 2) {
Thread.yield();
}
// 其他子线程执行完毕输出number
System.out.println(source.number);
}
}
//资源类
class DataSource2 {
int number = 0;
public void numberAdd1000() {
for (int i = 0; i < 1000; i++) {
this.number++;
}
}
}
运行结果:3611小于预期的5000,在多线程并发的情况下确实存在原子性的问题
反汇编:使用javap反汇编class文件,得到下面的字节码指令(截取部分)
9: getstatic #12 // Field number:I
12: iconst_1
13: iadd
14: putstatic #12 // Field number:I
分析:由此可见number++是由多条语句组成,以上多条指令在一个线程的情况下是不会出问题的,但是在多线程情况下就可能会出现问题。比如一个线程在执行13: iadd时,另一个线程又执行9: getstatic。会导致两次number++,实际上只加了1。
小结: 并发编程时,会出现原子性问题,当一个线程对共享变量操作到一半时,另外的线程也有可能来操作共享变量,干扰了前一个线程的操作。
3.有序性
有序性(Ordering):是指程序中代码的执行顺序,Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。
public static void main(String[] args) {
int a = 10;
int b = 20;
}
即在实际运行中,程序可能是先执行 int b =20;这个语句,而后再执行 int a = 10的语句,前提是两个语句之间没有依赖关系。
小结: 程序代码在执行过程中的先后顺序,由于Java在编译期以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序。
二、Java内存模型
- Java内存模型,是Java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。
- Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,具体如下。
-
主内存
主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。
-
工作内存
每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量。
Java内存模型的作用:Java内存模型是一套在多线程读写共享数据时,对共享数据的可见性、有序性、和原子性的规则和保障。
小结: Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。
三、主内存与工作内存之间的交互
Java内存模型中定义了以下8种操作来完成,主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
对应如下的流程图:
注意:
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中
小结
主内存与工作内存之间的数据交互过程
lock -> read -> load -> use -> assign -> store -> write -> unlock
四、synchronized保证三大特性
1.synchronized与原子性
使用synchronized保证原子性
package synchronizedProject;
public class AutomicDemo {
static int number = 0;
public static void main(String[] args) throws InterruptedException {
// 共享资源类
DataSource2 source = new DataSource2();
// 开启5个线程进行++操作
for (int i = 1; i <= 5; i++) {
new Thread(() -> {
source.numberAdd1000();
}, String.valueOf(i)).start();
}
// 当除了主线程后台的GC线程之外,还有其他子线程,那么主线程yield
while (Thread.activeCount() > 2) {
Thread.yield();
}
// 其他子线程执行完毕输出number
System.out.println(source.number);
}
}
//资源类
class DataSource2 {
int number = 0;
public void numberAdd1000() {
for (int i = 0; i < 1000; i++) {
// 添加synchronized代码块
synchronized (DataSource2.class) {
this.number++;
}
}
}
}
运行结果: 可见添加了synchronized代码块后解决了多线程并发的可见性问题
原理: 对number++;增加同步代码块后,保证同一时间只有一个线程操作number++;。就不会出现安全问题。
小结: synchronized保证原子性的原理,synchronized保证只有一个线程拿到锁,能够进入同步代码块。
2.synchronized与可见性
使用synchronized保证可见性
package synchronizedProject;
public class SeeOkDemo {
public static void main(String[] args) throws InterruptedException {
// 共享资源类
DataSource dataSource = new DataSource();
// 第一个线程
new Thread(() -> {
// 在第二个线程修改资源类的内容后,第一个线程不能感知到
while (dataSource.flag) {
// 在while循环里添加synchronized代码块
synchronized (dataSource) {
}
}
System.out.println("2s后~~");
}, "线程1").start();
// 主线程休眠2秒
Thread.sleep(2000);
// 第二个线程,修改资源类的内容
new Thread(() -> {
dataSource.changeFlagToFalse();
}, "线程2").start();
}
}
//资源类
class DataSource {
boolean flag = true;
// 对flag属性进行修改
public void changeFlagToFalse() {
this.flag = false;
System.out.println(Thread.currentThread().getName() + "\t 我把falg修改为flase~~");
}
}
运行结果: 2s后~ 打印出来了,说明跳出了while循环,可见synchronized解决了可见性的问题
原理: synchronized保证可见性的原理,执行synchronized时,会对应lock原子操作会刷新工作内存中共享变量的值
结合下图理解:
3.synchronized与有序性
synchronized保证有序性的原理:synchronized后,虽然进行了重排序,保证只有一个线程会进入同步代码块,也能保证有序性。
小结:synchronized保证有序性的原理,我们加synchronized后,依然会发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码中的代码。保证有序性
四、synchronized的特性
1.可重入特性
一个线程可以多次执行synchronized,重复获取同一把锁。
package synchronizedProject;
/**
* synchronized是可重入锁
*
* @author 阿楠
*
*/
public class ReentrantDemo {
public static void main(String[] args) {
// 共享资源类对象
Resource resource = new Resource();
// 线程1
new Thread(() -> {
resource.test();
}, "线程1").start();
}
// test2()方法,内部带有synchronized代码块
public static void test2() {
synchronized (Resource.class) {
System.out.println(Thread.currentThread().getName() + "进入同步代码块2");
}
}
}
//资源类
class Resource {
public void test() {
// test()方法内部有synchronized代码块
synchronized (this) {
System.out.println(Thread.currentThread().getName() + "进入同步代码块1");
// 执行test2()方法,里面也有同步代码块
ReentrantDemo.test2();
}
}
}
运行结果:线程1依次进入了带有synchronized代码块的逻辑方法中。
可重入的原理: synchronized的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁.
可重入的好处:
- 可以避免死锁
- 可以让我们更好的来封装代码
小结: synchronized是可重入锁,内部锁对象中会有一个计数器记录线程获取几次锁啦,在执行完同步代码块时,计数器的数量会-1,知道计数器的数量为0,就释放这个锁。
2.不可中断特性
一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断。
- synchronized属于不可被中断
- Lock的lock方法是不可中断的
- Lock的tryLock方法是可中断的
五、synchronized原理
我们要看synchronized的原理,但是synchronized是一个关键字,看不到源码。我们可以将class文件进行反汇编。
monitorenter: synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner:拥有这把锁的线程,recursions会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待
monitorexit:
- 能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。
- 执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
- monitorexit释放锁。monitorexit插入在
方法结束处和异常处
,JVM保证每个monitorenter必须有对应的monitorexit。
同步方法: 同步方法在反汇编后,会增加ACC_SYNCHRONIZED 修饰。会隐式调用monitorenter和monitorexit。
小结
通过javap反汇编我们看到synchronized使用编程了monitorentor和monitorexit两个指令.每个锁对象都会关联一个monitor(监视器,它才是真正的锁对象),它内部有两个重要的成员变量owner会保存获得锁的线程,recursions会保存线程获得锁的次数,当执行到monitorexit时,recursions会-1,当计数器减到0时这个线程就会释放锁
面试题:synchronized与Lock的区别
- synchronized是关键字,而Lock是一个接口。
- synchronized会自动释放锁,而Lock必须手动释放锁。
- synchronized是不可中断的,Lock可以中断也可以不中断。
- 通过Lock可以知道线程有没有拿到锁,而synchronized不能。
- synchronized能锁住方法和代码块,而Lock只能锁住代码块。
- Lock可以使用读锁提高多线程读效率。
- synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。