并发编程-线程同步

1 线程同步机制

  • 线程同步机制是一套用于协调线程之间的数据访问的机制,该机制可以保障线程安全。
  • Java平台提供的线程同步机制包括:
    1.锁
    2.volatile关键字
    3.final关键字
    4.static关键字
    5.以及相关的API。如Object。wait(),Object.notify()等

1.1 锁

  1. 将多个线程对共享数据的并发访问转化为串行访问,即一个共享数据一次只能被一个线程访问,锁就是利用这种思路来保障线程安全的。
  2. 锁(Lock)可以理解为对共享数据进行保护的一个许可证。一个线程在访问共享数据前必须先获得锁,获得锁的线程称为锁的持有线程。一个锁一次只能被一个线程持有,锁的持有线程在获得锁之后和释放锁之前这段时间,锁执行的代码称为临界区(Critical Section)。
  3. 锁具有排他性,即一个锁一次只能被一个线程持有,这种锁称为排他锁或互斥锁。


    image.png
  4. JVM把锁分为内部锁和显示锁
    • 内部锁通过 synchronized 关键字实现
    • 显示锁通过 java.util.concurrent.locks.lock 接口的实现类实现。

1.1.1 锁的作用

锁可以实现对共享数据的安全访问,保障线程的原子性、可见性和有序性。

  1. 锁是通过互斥保障原子性:一个锁只能被一个线程持有,这就保证了临界区的代码一次只能被一个线程所执行,使得临界区代码所执行的操作自然而然的具有不可分割的特性,即具备了原子性。
  2. 可见性的保障是通过写线程冲刷处理器的缓存和读线程刷新处理器缓存这两个操作来实现的。
  3. 在 Java 平台中,锁的获得隐含着刷新处理器缓存的操作,锁的释放隐含着冲刷处理器缓存的操作。
  4. 锁能够保障有序性

注意:使用锁保障线程的安全性,必须满足一下两个条件:

  • 这些线程在访问共享数据时必须使用同一把锁
  • 即使读取共享数据的线程也需要使用同一把锁

1.1.2 锁的相关特性

1.1.2.1 可重入性

可重入性(Reentrance)是指一个线程持有该锁时能再次申请该锁。

void methodA(){ 
    //第一次申请 a 锁,锁计数器 +1,锁计数器默认为0
    methodB();//调用了methodB()方法,methodB()方法的调用也需要a锁
    //释放 a 锁 ,锁计数器 -1
}
void methodB(){ 
    //第二次申请 a 锁,锁计数器 +1
    .... 
    //释放第二次申请的 a 锁 ,锁计数器 -1
}

1.1.2.2 公平锁与非公平锁

Java 平台中内部锁属于非公平锁,显示锁 Lock 既支持公平锁也支持非公平锁
公平锁:先来的先得到锁
非公平锁:先来的未必先得到锁

1.1.2.3 锁的粒度

一个锁可以保护的共享数据的数量大小称为锁的粒度。

  1. 锁保护共享数据量大,称该锁的粒度粗,否则就称该锁的粒度细。
  2. 锁的粒度过粗会导致线程在申请锁时进行不必要的等待,锁的粒度过细会增加锁调度的开销

1.1.3 内部锁:synchronized 关键字

Java中每个对象都有一个与之关联的内部锁这种锁也称为监视器(Monitor),这种锁是一种排他锁,可以保障原子性、可见性和有序性。
内部锁是通过 synchronized 关键字实现的, synchronized 关键字可以修饰代码块,可以修饰方法

  • 修饰代码块的语法:
synchronized(对象锁){
    //同步代码块,可以在同步代码块中访问共享数据 
}
  • 修饰实例方法称为同步实例方法:
//锁对象是 this
public synchronized void methodA(){
    
}
  • 修饰静态方法,称为同步静态方法:
//锁对象是 类名.class
public static synchronized void methodA(){
    
}

1.1.4脏读

脏读是指出现读取属性值出现了一些意外,读取的是中间值,而不是修改后的值。
出现的原因:对共享数据的修改 与 对共享数据的读取不同步锁造成的。
解决方案:不仅对修改数据的代码块进行同步,还要对读取数据的代码块同步

案例:

public static void main(String[] args) throws InterruptedException {
        /**
         * 需求:
         * (1)开启子线程设置用户名和密码
         * (2)在 main 线程中读取用户名,密码
         */
        //开启子线程设置用户名和密码
        User user = new User();
        SubThread t1 = new SubThread(user);
        t1.start();
        //为了确定设置成功,休眠100毫秒,休眠时间要比setValue中的休眠时间短。
        Thread.sleep(100);
        //在 main 线程中读取用户名,密码
        user.getValue();
    }

    //定义线程,设置用户名和密码
    static class SubThread extends Thread {
        private User user;

        public SubThread(User user) {
            this.user = user;
        }

        @Override
        public void run() {
            user.setValue("sunmer", "123");
        }
    }

    static class User {
        private String name = "haha";
        private String pwd = "666";
        //非同步方法
        public void getValue() {
            System.out.println(Thread.currentThread().getName() + ",getter name: " + name
                    + ",--pwd: " + pwd);
        }
        //同步方法
        public synchronized void setValue(String name, String pwd) {
            this.name = name;
            try {
                Thread.sleep(1000);//模拟操作 name 属性需要一定时间
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.pwd = pwd;
            System.out.println(Thread.currentThread().getName() + ",getter --name:" + name
                    + ", --pwd: " + pwd);
        }
    }
}

运行结果:
main,getter name: sunmer,--pwd: 666
Thread-0,getter --name:sunmer, --pwd: 123
结果分析:
观察运行结果,发现主线程读取的pwd是666,子线程读取的是123,数据不同步。
解决方法:对getValue()方法也使用同步
将:

public void getValue() {
    
}

修改为:

public synchronized void getValue() {
    
}

1.1.5 线程出现异常会自动释放锁

同步过程中线程出现异常,会自动释放锁对象,并不会影响其他线程的执行

1.1.6 死锁

1.1.6.1 死锁

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期的阻塞,因此程序不可能被终止。

1.1.6.2 死锁产生的四个必要条件
  1. 互斥使用:即当资源被一个线程使用(占有)时,别的线程不能使用。
  2. 不可抢占:资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
  3. 请求和保持:即当资源请求者在请求其它资源的同时保持对原有资源的占有。
  4. 循环等待:即存在一个等待队列,P1占有P2的资源,P2占有P1的资源。形成一个等待环路。
    案例:
public class Thread {
    public static void main(String[] args) {
        SubThread t1 = new SubThread();
        t1.setName("a");
        t1.start();
        SubThread t2 = new SubThread();
        t2.setName("b");
        t2.start();
    }

    static class SubThread extends Thread {
        private static final Object lock1 = new Object();
        private static final Object lock2 = new Object();

        @Override
        public void run() {
            if ("a".equals(Thread.currentThread().getName())) {
                synchronized (lock1) {
                    System.out.println("a 线程获得了 lock1 锁,还需要获得 lock2 锁");
                    synchronized (lock2) {
                        System.out.println("a 线程获得 lock1 后又获得了 lock2,可以想干任何想干的事");
                    }
                }
            }
            if ("b".equals(Thread.currentThread().getName())) {
                synchronized (lock2) {
                    System.out.println("b 线程获得了 lock2 锁,还需要获得 lock1 锁");
                    synchronized (lock1) {
                        System.out.println("b 线程获得lock2后又获得了 lock1,可以想干任何想干的事");
                    }
                }
            }
        }
    }
}

解决的办法也很简单:“b”线程交互lock1和lock2的顺序就可以解除死锁。或者 “a”线程交互lock1和lock2的顺序

2 轻量级同步机制:volatile关键字

volatile作用:使变量在多个线程间可见
但volatile不具备原子性

volatile与synchronized的区别:
  1. volatile关键字是线程同步的轻量级实现,性能比synchronized要好。
  2. volatile 之修饰变量 ,而synchronized可以修饰方法、代码块。
  3. 多线程访问 volatile 不会发生阻塞,而synchronized可能会发生阻塞。
  4. volatile 能保证数据的可见性,但不能保证原子性,而synchronized可以保证原子性,也可以保证可见性。
  5. 关键字 volatile 解决的是变量在多个线程间的可见性,synchronized 关键字解决多个线程之间访问公共资源的同步性。
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容