这段笔记是参照b站教程BV1Rv411y7MU整理而来的,用于个人备忘以便复习,需要的朋友可以自取。
线程同步(上)
1. 线程同步机制介绍
线程同步机制是一套用于协调线程之间的数据访问的机制。该机制可以保障线程安全。
Java平台提供的线程同步机制包括:锁,volatile关键字,final关键字,static关键字,以及相关的API如Object.wait()、Object.notify()等。
2. 锁
2.1 锁的概述
线程安全问题的产生前提是多个线程并发访问共享数据。
将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个和线程访问,锁就是复用这种思路来保障线程的安全。
锁(Lock)可以理解为对共享数据进行保护的一个许可证,对于同一个许可证保护的共享数据来说,任何线程都想要访问这些共享数据都必须持有该许可证。一个线程只有在持有许可证的情况下才能对这些共享数据进行访问。且一个许可证一次只能被一个线程持有。许可证线程在结束对共享数据的访问后必须释放其持有的许可证。
一个线程在访问共享数据之前必须获得锁,获得锁的线程称之为锁的持有线程;一个锁一次只能被一个线程持有,锁的持有线程在获得锁之后和释放锁之前的这段时间所执行的代码成为临界区(Critical Section)。
锁具有排他性(Exclusive),即一个锁一次只能被一个线程持有,这种锁称为排他锁或者互斥锁(Mutex)。
![](https://i-blog.csdnimg.cn/blog_migrate/63f2e3c080ae7261eb6949310f659943.png)
JVM把锁分为内部所和显示锁两种。内部所通过synchronized关键字实现;显示锁通过java.concurrent.locks.Lock接口实现类实现。
2.2 锁的作用
锁可以实现共享数据的安全访问。锁可以保障线程的原子性、可见性和有序性。
锁通过互斥访问来保障原子性。一个锁只能被一个线程持有,这保证了临界区代码一次只能被一个线程执行。使得临界区代码所执行的操作自然而然的具有不可分割的特性,即具备了原子性。
可见性的保障是通过写线程冲刷处理器缓存和读线程刷新处理器缓存这两个动作实现的。在Java平台中,锁的获得隐含这刷新处理器缓存的动作;锁的释放隐含着冲刷处理器缓存的动作。
锁能够保障有序性,写线程在临界区所执行的在读线程所执行的临界区看起来像是完全按照代码顺序执行的。
注意: 使用锁保障线程的安全性,必须满足以下条件:
- 这些线程访问共享数据时必须使用同一个锁。
- 即使是读取共享数据的线程也要使用同步锁。
2.3 锁的相关概念
-
可重入性
可重入性(Reentrancy)描述这样一个问题:一个线程持有该锁的时候能够再次(多次)申请改锁。void methodA(){
申请A锁
methodB();
释放A锁
}void methodB(){
申请A锁
…
释放A锁
}- 如果一个线程持有一个锁的时候还能够继续成功申请该锁,则称该锁是可重入的,否则称该锁不可重入。
-
锁的争用与调度
Java平台中内部锁属于非公平锁,显示Lock锁既支持公平锁也支持非公平锁。 -
锁的粒度
一个锁可以保护的共享数据的数量大小称为锁的粒度。如果一个锁保护的共享数据量大,则称该锁的粒度粗,否则就称该锁的粒度细。
锁的粒度过粗会导致线程申请锁的时候会进行不必要的等待,锁的粒度过细会导致增加锁调度的开销。
2.4 内部锁:synchronized关键字
Java中每一个对象都有一个与之关联的内部所,这种锁也称为监视器(Monitor),这种内部锁是一种排他锁,可以保障原子性、可见性和有序性。
内部锁是通过synchronized关键字来实现的。synchronized关键字修饰代码块、方法。
- 修饰代码块
synchronized(对象锁){ ... 同步代码块,可以在同步代码块中访问共享数据 ... }
- 修饰实例方法就称之为同步实例方法
- 修饰静态方法就称之为同步静态方法
/**
*假设Thread-0线程获得CPU执行权,调用main对象mm()方法
* 执行方法体,先获得this对象main01的锁
*
* 假设Thread-0在执行临界区代码期间,Thread-1线程获得CPU执行权,
* 调用main01对象的mm()方法,调用方法体,先获得this对象的main01锁
* 而现在Thread-0线程持有this对象的main01锁,synchronized内部锁是排他锁,只能被一个线程锁持有,
* Thread-1进入等待区进行等待this对象的main01锁
*
* 当Thread-0线程重新获得CPU执行权,把临界区代码执行完之后,Thread-0线程会释放this对象的main01锁,
* 等待区的Thread-1对象获得this对象的main01锁,开始执行临界区代码
*/
public class main{
public static void main(String[]args) throws InterruptedException {
/**
* 创建两个线程,分别调用mm()方法
* 先创建main01对象,通过对象名调用mm()方法
*/
main main01 = new main();
/*Thread-0*/
new Thread(new Runnable() {
@Override
public void run() {
main01.mm(); //使用的锁对象this就是main01对象
}
}).start();
/*Thread_1*/
new Thread(new Runnable() {
@Override
public void run() {
main01.mm(); //使用的锁对象this就是main01对象
}
}).start();
}
//定义方法,打印100行字符串
public void mm(){
synchronized (this){// 经常使用this当前对象作为锁对象
for(int i=0;i<100;i++){
System.out.println(Thread.currentThread().getName()+" -> "+i);
}
}
}
}
- 想要同步必须使用同一把锁
- 锁对象不同的情况下不能实现同步,如将上述代码中main方法略微调整,再实例一个新的main对象,两个线程分别调用不同的实例对象的mm方法,就不会产生同步。两个线程分别在执行不同的临界区的代码,如下:
public static void main(String[]args){ main main01 = new main(); main main02 = new main(); new Thread(new Runnable() { @Override public void run(){ main01.mm(); //使用的锁对象是this是main01对象 } }).start(); new Thread(new Runnable() { @Override public void run(){ main02.mm(); //使用的锁对象是this是main02对象 } }).start(); }
通常也可以将常量设置为锁对象:
public class main{
......
public static final Object OBJ = new Object();//定义一个常量
public void mm(){
synchronized(OBJ){//使用一个常量作为锁对象
......
}
}
}
public static void main(String[]args) throws InterruptedException {
main main01 = new main();
/*Thread-0*/
new Thread(new Runnable() {
@Override
public void run() {
main01.mm(); //使用的锁对象是OBJ常量
}
}).start();
/*Thread-1*/
new Thread(new Runnable() {
@Override
public void run() {
main01.mm(); //使用的锁对象是OBJ常量
}
}).start();
/*Thread-2*/
new Thread(new Runnable() {
@Override
public void run() {
sm(); //使用的锁对象this就是main01对象
}
}).start();
}
//定义方法,打印100行字符串
public static final Object OBJ = new Object();
public void mm(){
synchronized (OBJ){
for(int i=0;i<100;i++){
System.out.println(Thread.currentThread().getName()+" -> "+i);
}
}
}
//静态方法上锁
public static void sm(){
synchronized (OBJ){
for(int i=0;i<100;i++){
System.out.println(Thread.currentThread().getName()+" --> "+i);
}
}
}
}
此时会发现上述代码控制台打印的时候,Thread-0打印结束之后Thread-2先于Thread-1打印,但是将静态方法上锁的实现进程移至第一个的时候,顺序又正常了。
这里我给出的理解是(上述代码为例),执行顺序为代码顺序,因此Thread-0先工作,由于mm和sm方法是通过一把锁,因此Thread-1和Thread-2启动后进入等待区等待Thread-0释放锁。
由于Thread-2实现的方法属于静态方法,属于类,而Thread-1实现的mm方法属于实例对象,因此存在优先级问题。导致等待区中Thread-2的执行优先级比Thread-1要高,所以上述形式打印的时候先输出Thread-2后输出Thread-1。
而将Thread-2移动到最顶部后,此时最先启动的就是原本的Thread-2,所以静态方法访问临界区的时候,原本额Thread-1和Thread-2进入等待区,且其等待顺序按照代码顺序,输出无误。【上述为个人观点】
也可以使用synchronized修饰实例方法,同步实例方法,默认this作为锁对象
public synchronized void mm22(){
for(int i=0;i<100;i++){
......
}
}
使用synchronized修饰静态方法,同步静态方法,默认运行时类作为锁对象
public synchronized static void mm22(){
for(int i=0;i<100;i++){
.......
}
}
- 数据脏读
以下方法中读取会产生脏读。即读取某些属性的时候出现一些意外,读取到了中间值,而没有准确读取到修改后的值。
出现脏读的原因是,对共享数据的修改与对共享变量的读取不同步。
public static void main(String[]args) throws InterruptedException {
//开启子线程,设置用户名和密码
publicValue pv = new publicValue();
SubThread thread = new SubThread(pv);
thread.start();
//为了确定设置成功,等待1s
Thread.sleep(1000);
//在main线程中读取用户名
pv.getValue();
}
//定义线程设置用户名和密码
public static class SubThread extends Thread{
private publicValue pv;
public SubThread(publicValue pv){
this.pv = pv;
}
@Override
public void run() {
try {
pv.setValue("xiye!!!","123456");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static class publicValue{
private String name = "xiye";
private String pwd = "000";
public void setValue(String name,String pwd) throws InterruptedException {
this.name=name;
//暂停一秒
Thread.sleep(1000);
this.pwd=pwd;
System.out.println(Thread.currentThread().getName()+" -> "+this.name+" --> "+this.pwd);
}
public void getValue(){
System.out.println(Thread.currentThread().getName()+" -> "+this.name+" --> "+this.pwd);
}
}
}
修改方法是将get和set方法改为同步方法,即添加synchronized关键字
public synchronized void setValue(String name,String pwd){}
public synchronized void getValue(){ }
-
程序异常自动释放锁
同步过程中出现异常会自动释放锁。 -
死锁
在多线程程序中,同步时可能需要使用多个锁,如果获得锁的顺序不一致,可能导致死锁。
public static void main(String[]args) throws InterruptedException {
SubThread thread_1 = new SubThread();
thread_1.setName("a");
thread_1.start();
SubThread thread_2 = new SubThread();
thread_2.setName("b");
thread_2.start();
}
static class SubThread extends Thread{
private static final Object lock_1 = new Object();
private static final Object lock_2 = new Object();
@Override
public void run() {
if("a".equals(Thread.currentThread().getName())){
synchronized (lock_1){
System.out.println("a线程获得了lock_1锁,还需要获得lock_2锁");
synchronized (lock_2){
System.out.println("a线程获得了lock_1锁之后,又获得了lock_2锁,a线程可以执行任务了。。。");
}
}
}
if("b".equals(Thread.currentThread().getName())){
synchronized (lock_2){
System.out.println("b线程获得了lock_2锁,还需要获得lock_1锁");
synchronized (lock_1){
System.out.println("b线程获得了lock_2锁之后,又获得了lock_1锁,b线程可以执行任务了。。。");
}
}
}
}
}
}
如何避免死锁:即当需要获得多个锁的时候,所有线程获得锁的顺序保持一直。则上述代码中两个if语句中锁对象顺序保持一直:
//两个if语句都修改为以下方式
if(...){
synchronized(lock_1){
...
synchronized(lock_2){
...
}
}
}
//或者
if(...){
synchronized(lock_2){
...
synchronized(lock_1){
...
}
}
}
2.5 轻量级同步机制: volatile关键字
2.5.1 volatile的作用
volatile关键字的作用是使得变量在多个线程之间可用。
volatile可以强制从公共内存中读取变量的值,而不是从工作内存中读取。
以下程序执行会发生死循环错误,原因是直接使用多线程时候,Thread-0线程将实例对象存储到了工作内存之中,所以Thread-0一直在运行打印函数。而1秒之后main线程开始执行setContinuePrint()
方法,将答应标志置为false,但此时修改不会被Thread-0线程读取到。
但是如果将printStringMethod()
方法中while的注释打开的话,程序会正常结束。原因是Thread-0循环打印结束之后会睡眠500毫秒,而一秒之后main线程将打印标志置为false之后,Thread-0被唤醒开始执行第三次循环,但此时访问循环变量的时候已经变成了
public class Main {
public static void main(String[]args) throws InterruptedException {
//创建PrintString对象
PrintString printString = new PrintString();
//printString.printStringMethod();
//创建子线程,子线程执行打印方法
new Thread(new Runnable() {
@Override
public void run() {
try {
printString.printStringMethod();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
Thread.sleep(1000);
System.out.println("在main中修改打印标志");
printString.setContinuePrint(false);
}
//定义类 打印字符串
static class PrintString{
private boolean continuePrint = true;
public PrintString setContinuePrint(boolean continuePrint){
this.continuePrint=continuePrint;
return this;
}
public void printStringMethod() throws InterruptedException {
System.out.println(Thread.currentThread().getName()+" -- Begin");
while (this.continuePrint){
//System.out.println(Thread.currentThread().getName()+"...sub thread");
//Thread.sleep(500);
}
System.out.println(Thread.currentThread().getName()+" -- End");
}
}
}
修改方法:在打印标志前添加volatile关键字
public volatile boolean continurPrint = true;
- volatile与synchronized比较
- volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized好;volatile只能修饰变量,而synchronized还可以修饰方法、代码块。随着JDK新版本的发布,synchronized的执行效率也有较大的提高,在开发中使用synchronized的比例较大。
- 多线程访问volatile变量不会发生阻塞,而synchronized可能会阻塞。
- volatile能保证数据的可见性,但不能保证原子性;而synchronized可以保证原子性,也可以保证可见性。
- 关键字volatile解决的是变量在多个线程之间的可见性;synchronized关键字解决的是多个线程之间访问公共资源的同步性。
2.5.2 volatile的非原子性
volatile关键字增加了实例变量在多个线程之间的可见性,但是不具备原子性。
public class Main01 {
public static void main(String[]args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
new MyThread().start();
}
}
static class MyThread extends Thread{
//volatile仅仅表示所有所有线程直接从主内存中读取count变量的值
public volatile static int count;
public synchronized static void addCount(){
for (int i = 0; i < 1000; i++) {
count++;
}
System.out.println(Thread.currentThread().getName()+" -> "+count);
}
@Override
public void run() {
addCount();
}
}
}