23.线程同步

本章目标

  1. 线程安全
  2. 同步锁
  3. 同步之Lock
  4. 同步之volitale(扩展)
  5. 同步之ThreadLocal(扩展)

本章内容

一、 线程安全

线程安全指的是多线程调用同一个对象的临界区的方法时,对象的属性值一定不会发生错误,这就是保证了线程安全

1、共享资源

多个各并行线程都会访问的资源;一个程序运行多个线程本身是没有问题,问题有可能出现在多个线程访问共享资源,当多个线程都是读共享资源也是没有问题,当多个线程读写共享资源时,如果发生指令交错,就会出现问题。

2、 临界资源

临界资源是一次仅允许一个进程使用的共享资源。各进程采取互斥的方式,实现共享的资源称作临界资源。各进程间采取互斥方式,实现对这种资源的共享。

3、 临界区:

每个进程中访问临界资源的那段代码称为临界区(criticalsection),每次只允许一个进程进入临界区,进入后,不允许其他进程进入。不论是硬件临界资源还是软件临界资源,多个进程必须互斥的对它进行访问。

4、 互斥

互斥是指当多个线程需要访问同一资源时,要求在一个时间段内只能允许一个线程来操作共享资源,操作完毕后别的线程才能读取该资源,具有唯一性和排它性。

互斥是通过竞争对资源的独占使用,彼此之间不需要知道对方的存在,执行顺序是一个乱序。

互斥是通过synchronized关键字即加锁的方式来实现的。

5、同步的实现方式

同步:是指在互斥的基础上通过其它机制实现访问者对资源的有序访问。而同步也是不能同时运行,但他是必须要安照某种次序来运行相应的线程(也是一种互斥)!

同步是协调多个相互关联线程合作完成任务,彼此之间知道对方存在,限制访问者对资源的访问顺序,即访问是按顺序进行的。

同步的实现方式:

  1. 同步方法: java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态
  2. 同步代码块 即有synchronized关键字修饰的语句块。 被该关键字修饰的语句块会自动被加上内置锁,从而实现同步
  3. wait与notify: 详细见:wait、notify、notifyAll的使用方法
  4. 使用重入锁实现线程同步 详见Lock接口,它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其能力
  5. 使用特殊域变量(volatile)实现线程同步 volatile关键字为域变量的访问提供了一种免锁机制
  6. 使用局部变量ThreadLocal实现线程同步: ThreadLocal的作用是提供线程内的局部变量,这种变量在多线程环境下访问时能够保证各个线程里变量的独立性

二、同步锁

同步锁也叫对象锁,用于保证线程安全的,是阻塞式的解决方案。

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性

1、 同步锁分类

加同步锁的方式有两种:

同步方法:

就是在方法前面加上关键字synchronized,即实现了方法的同步。

public synchronized void show(){
    //功能代码
}

同步对象:

就是在需要同步的区域实现同步块。

public  void show(){
    synchronized (this){
     //功能代码
    }
}

2、 实现

public class Ticket {
    //火车票剩余1张
    private static int ticketNum = 1;
    public void getTicket(String name) throws InterruptedException {
        synchronized (this) {
            if (ticketNum>0) {
                //模拟查询时有票,一秒后去购买
                Thread.sleep(1000);
                //购买成功,票数量减1
                ticketNum--;
                System.out.println(name+"购买成功,剩余票"+ticketNum+"张");
            }else {
                System.out.println("没票了");
            }
        }

    }
}
public class MyRunnable implements Runnable{
    Ticket ticket = new Ticket();
    @Override
    public void run() {
        try {
            ticket.getTicket(Thread.currentThread().getName());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
public class MyTest {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable runnable = new MyRunnable();
        Thread ta = new Thread(runnable, "窗口A");
        Thread tb = new Thread(runnable, "窗口B");
        ta.start();
        tb.start();
    }
}

未加synchronized (this) 之前会是什么效果?

三、 同步之Lock

1、 synchronized的缺点

如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:

  • 获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
  • 线程执行发生异常,此时JVM会让线程自动释放锁。

如果这个获取锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能等待,这非常影响程序执行效率。

因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过Lock就可以办到。

2、 Lock

在JavaSE5.0中新增了一个java.util.concurrent包(JUC)来支持同步。

在Java中,Lock是一个接口,它提供了比synchronized关键字更高级的线程同步机制。使用Lock接口可以创建更复杂和灵活的同步结构。Lock接口的常用实现类有ReentrantLock和ReentrantReadWriteLock,它们提供了可重入的互斥锁和读写锁。

ReentrantLock类是可重入、互斥、实现了Lock接口的锁, 它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其能力。

2.1、创建一个Lock对象实例

Lock lock = new ReentrantLock();

2.2、使用锁

lock.lock();//获取锁
try {
    // 同步的代码
} finally {
    // 在finally块中释放锁,以确保锁的释放
    lock.unlock();
}

一定要手动释放锁

2.3、示例:

public class Ticket {
    //火车票剩余1张
    private static int ticketNum = 1;
    public void getTicket(String name) throws InterruptedException {
        if (ticketNum>0) {
            //模拟查询时有票,一秒后去购买
            Thread.sleep(1000);
            //购买成功,票数量减1
            ticketNum--;
            System.out.println(name+"购买成功,剩余票"+ticketNum+"张");
        }else {
            System.out.println("没票了");
        }

    }
}
//特别要注意Lock的位置
public class MyRunnable implements Runnable{
    Ticket ticket = new Ticket();
    //注意Lock的位置,要直接在实现类下声明,不能放到run方法中声明,
    Lock lock = new ReentrantLock();
    @Override
    public void run() {
        try {
            lock.lock();
            ticket.getTicket(Thread.currentThread().getName());
            System.out.println("aa");
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}
public class MyTest {
    public static void main(String[] args) throws InterruptedException {
        MyRunnable runnable = new MyRunnable();
        Thread ta = new Thread(runnable, "窗口A");
        Thread tb = new Thread(runnable, "窗口B");
        ta.start();
        tb.start();
    }
}

注意Lock锁声明的位置:Lock同步代码块用try catch包起来,finally中写入unlock,避免死锁的发生

3、 Lock和synchronized的区别

Lock提供了比synchronized更多的功能。但是要注意以下几点:

  • lock是一个接口,是java写的控制锁的代码,而synchronized是java的一个内置关键字,synchronized是托管给JVM执行的
  • synchronized在发生异常时候会自动释放占有的锁,因此不会出现死锁;而lock发生异常时候,不会主动释放占有的锁,必须手动unlock来释放锁,可能引起死锁的发生。
  • synchronized等待锁过程中可以用interrupt来中断等待,而lock只能等待锁的释放,不能响应中断
  • Lock可以尝试获取锁,synchronized获取不到锁只能一直阻塞
  • synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、等待时可中断、可判断、可公平可非公平
  • synchronized使用Object对象本身的wait 、notify、notifyAll调度机制,而Lock可以使用Condition进行线程之间的调度

可重入锁:也叫做递归锁,指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。在JAVA环境下 ReentrantLock 和synchronized 都是可重入锁

一个线程如果获取了某个方法的锁,这个方法内部即使调用了别的需要获取锁的方法,那么这个线程不需要再次等待获取锁,可以直接进去

公平锁:sychronized是非公平锁,线程之间抢占资源顺序是随机,没有先到先得的规则,是允许插队的。一般业务上是不允许的,举个例子,在秒杀的业务场景下,一般比的是谁的手速和网速快,但在非公平锁的环境下,有可能后面点的慢或网速慢的人抢到了该商品,那是不是对于那些狂练手速和疯狂蹭网的很不公平。

ReentrantLock默认是非公平锁,构造函数重载方法ReentrantLock(boolean fair)支持传入true创建公平锁。

四、 同步之volitale(扩展)

线程在运行时都有一个线程栈,线程栈保存了线程运行时变量值信息。当线程访问某一个对象时候值的时候,首先通过对象的引用找到对应在堆内存的变量的值,然后把堆内存变量的具体值load到线程本地内存中,建立一个变量副本,之后线程就不再和对象在堆内存变量值有任何关系,而是直接修改副本变量的值.在修改完之后的某一个时刻(线程退出之前),自动把线程变量副本的值回写到对象在堆中变量。这样在堆中的对象的值就产生变化了

这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。 要解决这个问题,就需要把变量声明为volatile,这就指示JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。一般说来,多任务环境下,各任务间共享的变量都应该加volatile修饰符

img

public class VolitaleTest extends Thread {
    // 没有加volatile时程序并没有退出。vt线程仍然在运行,此时flag是在线程工作内存当中获取,而不是从“主内存”中获取
    volatile boolean flag = false;// 强制线程每次读取该值的时候都去“主内存”中取值
    int i = 0;
    @Override
    public void run() {
        while(!flag) {
            i++;
        }
        System.out.println("end");
    }
    public static void main(String[] args) {
        VolitaleTest vt = new VolitaleTest();
        vt.start();
        try {
            Thread.sleep(2000);
            vt.flag = true;
            System.out.println(vt.i);
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }

}

要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

五、 同步之ThreadLocal(扩展)

ThreadLocal的作用是提供线程内的局部变量,每一个使用该变量的线程都获得该变量的副本,副本之间相互独立,这样每一个线程都可以随意修改自己的变量副本,而不会对其他线程产生影响。

1、 ThreadLocal 类的常用方法:

  • ThreadLocal() : 创建一个线程本地变量
  • get() : 返回此线程局部变量的当前线程副本中的值
  • initialValue() : 返回此线程局部变量的当前线程的”初始值”
  • set(T value) : 将此线程局部变量的当前线程副本中的值设置为value

2、场景二

2.1、表述

多线程访问同一个共享变量很容易出现并发问题,特别是当多个线程对同一个共享变量进行写入操作时。一般为了避免这种情况,我们会使用synchronized这个关键字对代码块加锁。但是这种方式一是会让没获取到锁的线程进行阻塞等待,二是需要使用者对锁有一定的了解,无疑提高了编程的难度。其实ThreadLocal 就可以做这件事情,虽然ThreadLocal 并不是为了解决这个问题而出现的。 ThreadLocal 是JDK 包提供的,它提供了线程本地变量,也就是如果你创建了一个ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。

2.2、未使用ThreadLocal 之前

public class Bank {
    private int money;

    public int getMoney() {
        return money;
    }

    public void setMoney(int money) {
        this.money = this.money+money;
    }
}

2.3、测试

public class ThreadLocalTest {

    public static void main(String[] args) {
        Bank bank = new Bank();
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                bank.save(100);
                System.out.println(bank.getAccount());
            }
        });

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                bank.save(100);
                System.out.println(bank.getAccount());
            }
        });

        t1.start();
        t2.start();
    }
}

//结果
100
200

此时和Ticket示例效果一样,因为两个线程操作的是一个bank示例,所以第二个线程是在第一个线程结果基础上运行的

2.4、使用ThreadLocal

class Bank {
    // 使用ThreadLocal类管理共享变量account
    private static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return 100;
        }
    };

    public void save(int money) {
        threadLocal.set(threadLocal.get() + money);
    }

    public int getAccount() {
        return threadLocal.get();
    }

}

2.5、运行测试

//结果
200
200

我们发现第一线程对变量值进行了改变,但第二个线程在执行时并没有受第一线程的影响,说明第个线程操作是自己内存的变量

当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题

3、场景二 传递参数

3.1、问题:

在AccountService中写了一段类似这样的代码:

Context ctx = new Context();
ctx.setTrackerID(.....)

然后这个AccountService 调用了其他Java类,不知道经过了多少层调用以后,最终来到了一个叫做AccountUtil的地方,在这个类中需要使用Context中的trackerID来做点儿事情:

img

能不能把这个值就放到线程中? 让线程携带着这个值到处跑,这样我无论在任何地方都可以轻松获得了,这个类就是ThreadLocal

ThreadLocal不是用来解决共享对象的多线程访问问题的,而主要是提供了线程保持对象的方法和避免参数传递的方便的对象访问方式

3.2、传统方式:

public class Work {
    public void getInfo(User user){
    }
    public void checkInfo(User user){
    }
    public void changeInfo(User user){
    }
    public void doLog(User user){
    }
    public void work(User user){
        getInfo(user);
        changeInfo(user);
        changeInfo(user);
        doLog(user);
    }
}

3.3、ThreadLocal方式

public class ThreadLocalWork {
    //创建ThreadLocal对象
    private static ThreadLocal<User> userThreadLocal = new ThreadLocal(){
        @Override
        protected Object initialValue() {
            return new User();
        }
    };
    public void getInfo(){
        //获取ThreadLocal中的值
        User user = userThreadLocal.get();
        System.out.println(user);
    }
    public void checkInfo(){
    }
    public void changeInfo(){
    }
    public void doLog(){
    }
    public void work(User user){
        //设置ThreadLocal中的值
        userThreadLocal.set(user);
        getInfo();
        changeInfo();
        changeInfo();
        doLog();
    }
}

3.3、测试

public class Main {
    public static void main(String[] args) throws IOException, InterruptedException {
        ThreadLocalWork work = new ThreadLocalWork();
        work.work(new User(1001,"tom"));
    }
}

//User类
public class User {
    private int id;
    private String name;

    public User() {
    }

    public User(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", name='" + name + '\'' +
                '}';
    }
}

4、 ThreadLocal和synchronized 区别:

  • 对于多线程资源共享的问题,同步机制采用了“以时间换空间”的方式,而ThreadLocal采用了“以空间换时间”的方式。
  • 前者仅提供一份变量,让不同的线程排队访问, 效率降低,而后者为每一个线程都提供了一份变量,因此可以同时访问而互不影响

思维导图

image

posted @ 2025-04-09 14:41  icui4cu  阅读(26)  评论(0)    收藏  举报