Java 多线程同步与锁的问题(详细介绍)

Java多线程基础知识

关于Java多线程的基础可以参考:https://blog.csdn.net/m0_37798046/article/details/115294166

线程同步

线程的同步是为了防止多个线程访问一个数据对象时,对数据造成的破坏。

场景:

我们有一个钱包,里面有账户余额,这个钱包有存钱和取钱的操作,当钱包被两个线程同时操作时,一个取100块,一个存钱100块。假设账户原本有0块,如果取钱线程和存钱线程同时发生,会出现什么结果呢?

新建钱包类

public class Wallet {
    private int balance =0;//账户余额

    //存钱
    public  void saveMoney(int money){
        balance +=money;
    }

    //取钱
    public  void subMoney(int money){
        if(balance-money < 0){
            System.out.println("余额不足");
            return;
        }
        balance -=money;
    }

    //查询
    public void lookMoney(){
        System.out.println("账户余额:"+balance);
    }
}

新建WalletThread线程 

public class WalletThread implements Runnable{
    private Wallet wallet;
    private String title;

    public WalletThread(Wallet wallet, String title){
        this.wallet = wallet;
        this.title = title;
    }
    @Override
    public void run() {
        for (int i = 0; i < 5; i ++){
            wallet.saveMoney(100);
            System.out.println(title+"存进:100");
        }
        wallet.subMoney(200);
        System.out.println(title+"取出:200");
        wallet.lookMoney();
    }
}

新建测试 

@Test
public void test(){
    Wallet wallet = new Wallet();
    WalletThread thread1 = new WalletThread(wallet,"AA");
    WalletThread thread2 = new WalletThread(wallet,"BB");
    new Thread(thread1).start();
    new Thread(thread2).start();
}

输出结果

 从结果发现,这样的输出值明显是不合理的,原因是两个线程不加控制的访问Wallet对象并修改其数据所致。如果要保持结果的合理性,只需要达到一个目的,就是将对Wallet的访问加以限制,每次只能有一个线程在访问。这样就能保证Wallet对象中数据的合理性了。

同步和锁定

锁的原理

 Java语言包含两种内在的同步机制:同步块(或方法)和 volatile变量。这两种机制的提出都是为了实现代码线程的安全性。其中 Volatile变量的同步性较差(但有时它更简单并且开销更低),而且其使用也更容易出错。

Java中每个对象都有一个内置锁。

        当程序运行到非静态的synchronized同步方法上时,自动获得与正在执行代码类的当前实例(this实例)有关的锁。获得一个对象的锁也称为获取锁、锁定对象、在对象上锁定或在对象上同步。

        当程序运行到synchronized同步方法或代码块时才该对象锁才起作用。

        一个对象只有一个锁。所以,如果一个线程获得该锁,就没有其他线程可以获得锁,直到第一个线程释放(或返回)锁。这也意味着任何其他线程都不能进入该对象上的synchronized方法或代码块,直到该锁被释放。

释放锁是指持锁线程退出了synchronized同步方法或代码块。

关于锁和同步,有一下几个要点:

  • 只能同步方法,而不能同步变量和类;
  • 每个对象只有一个锁;当提到同步时,应该清楚在什么上同步?也就是说,在哪个对象上同步?
  • 不必同步类中所有的方法,类可以同时拥有同步和非同步方法。
  • 如果两个线程要执行一个类中的synchronized方法,并且两个线程使用相同的实例来调用方法,那么一次只能有一个线程能够执行方法,另一个需要等待,直到锁被释放。也就是说:如果一个线程在对象上获得一个锁,就没有任何其他线程可以进入(该对象的)类中的任何一个同步方法。
  • 如果线程拥有同步和非同步方法,则非同步方法可以被多个线程自由访问而不受锁的限制。
  • 线程睡眠时,它所持的任何锁都不会释放。
  • 线程可以获得多个锁。比如,在一个对象的同步方法里面调用另外一个对象的同步方法,则获取了两个对象的同步锁。
  • 同步损害并发性,应该尽可能缩小同步范围。同步不但可以同步整个方法,还可以同步方法中一部分代码块。

修改Wallet类

public class Wallet {
    private int balance =0;//账户余额

    //存钱
    public void saveMoney(int money){
        //同步代码块
        synchronized (this) {
            balance +=money;
        }
    }

    //取钱(同步方法)
    public synchronized void subMoney(int money){
        if(balance-money < 0){
            System.out.println("余额不足");
            return;
        }
        balance -=money;
    }

    //查询
    public void lookMoney(){
        System.out.println("账户余额:"+balance);
    }
}

运行结果:在方法上加入synchronized同步方法之后数据显示就正常了。

volatile实现线程同步

  • volatile关键字为域变量的访问提供了一种免锁机制 
  • 使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新 
  • 因此每次使用该域就要重新计算,而不是使用寄存器中的值 
  • volatile不会提供任何原子操作,它也不能用来修饰final类型的变量

考虑一个问题,为什么变量需要volatile来修饰呢?

要搞清楚这个问题,首先应该明白计算机内部都做什么了。比如做了一个i++操作,计算机内部做了三次处理:读取-修改-写入。

同样,对于一个long型数据,做了个赋值操作,在32系统下需要经过两步才能完成,先修改低32位,然后修改高32位。

假想一下,当将以上的操作放到一个多线程环境下操作时候,有可能出现的问题,是这些步骤执行了一部分,而另外一个线程就已经引用了变量值,这样就导致了读取脏数据的问题。

通过这个设想,就不难理解volatile关键字了。

volatile可以用在任何变量前面,但不能用于final变量前面,因为final型的变量是禁止修改的。也不存在线程安全的问题。

更多的内容,请参看:《Java理论与实践:正确使用 Volatile 变量》一文。

修改Wallet类

package thread.synch;

public class Wallet {
    private volatile int balance =0;//账户余额

    //存钱
    public void saveMoney(int money){
        balance +=money;
    }

    public void subMoney(int money){
        if(balance-money < 0){
            System.out.println("余额不足");
            return;
        }
        balance -=money;
    }

    //查询
    public void lookMoney(){
        System.out.println("账户余额:"+balance);
    }
}

运行结果

又乱了。这是为什么呢?就是因为volatile不能保证原子操作导致的,因此volatile不能代替synchronized。

重入锁实现线程同步

在JavaSE5.0中新增了一个java.util.concurrent包来支持同步。ReentrantLock类是可重入、互斥、实现了Lock接口的锁, 它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其能力。

在Wallet中添加ReentrantLock

import java.util.concurrent.locks.ReentrantLock;

public class Wallet {
    private int balance =0;//账户余额
    ReentrantLock lock = new ReentrantLock();

    //加入定额的钱
    public void saveMoney(){
        // 重入锁实现(使用异常捕获,避免异常照成死锁)
        lock.lock();
        try {
            balance += 300;
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    //查询
    public void lookMoney(){
        System.out.println("账户余额:"+balance);
    }
}

使用局部变量实现线程同步

public class Wallet {
    private static ThreadLocal<Integer>  balance = new ThreadLocal<Integer>(){
        @Override
        protected Integer initialValue(){
            return 0;
        }
    };//账户余额

    //存钱
    public void saveMoney(int money){
        balance.set(balance.get()+money);
    }

    //取钱(同步方法)
    public synchronized void subMoney(int money){
        if(balance.get() - money < 0){
            System.out.println("余额不足");
            return;
        }
        balance.set(balance.get() - money);
    }

    //查询
    public void lookMoney(){
        System.out.println("账户余额:"+balance);
    }
}

运行结果

如果使用ThreadLocal管理变量,则每一个使用该变量的线程都获得该变量的副本,副本之间相互独立,这样每一个线程都可以随意修改自己的变量副本,而不会对其他线程产生影响。现在明白了吧,原来每个线程运行的都是一个副本,也就是说存钱和取钱是两个账户,知识名字相同而已。所以就会发生上面的效果。

线程调度

wait():使一个线程处于等待状态,并且释放所持有的对象的lock。

sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要捕捉InterruptedException异常。

notify():唤醒一个处于等待状态的线程,注意的是在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由JVM确定唤醒哪个线程,而且不是按优先级。

notityAll():唤醒所有处入等待状态的线程,注意并不是给所有唤醒线程一个对象的锁,而是让它们竞争。

必须从同步代码块内调用wait()、notify()、notifyAll()方法。

新建ThreadA

public class ThreadA extends Thread{

    private Integer count;

    public ThreadA(Integer count){
        this.count = count;
    }

    public Integer getCount(){
        return count;
    }

    @Override
    public void run() {
        synchronized (this){
            for (int i = 1; i < 101; i ++){
                count+=i;
            }
            // 唤醒所有等待线程
            notifyAll();
        }
    }
}

 新建ThreadB线程

public class ThreadB extends Thread{

    private ThreadA thread;

    public ThreadB(ThreadA thread){
        this.thread = thread;
    }

    @Override
    public void run(){
        synchronized (thread){
            try {
                System.out.println("等待线程计算完成...");
                thread.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("计算总和是:"+thread.getCount());
        }
    }
}

新建测试

@Test
public void test2(){
	ThreadA a = new ThreadA(0);
	new ThreadB(a).start();
	new ThreadB(a).start();
	new ThreadB(a).start();
	a.start();
}

 运行结果

结论

因为无法保证线程的不同部分将按照什么顺序来执行。幸运的是当读取线程运行时,它只能马上进入等待状态----它没有做任何事情来检查等待的事件是否已经发生。 ----因此,如果计算线程已经调用了notifyAll()方法,那么它就不会再次调用notifyAll(),并且等待的读取线程将永远保持等待。

通常,解决上面问题的最佳方式是利用某种循环,该循环检查某个条件表达式,只有当正在等待的事情还没有发生的情况下,它才继续等待。

守护线程

  • 在Java中有两类线程:User Thread(用户线程)、Daemon Thread(守护线程) 
  • 用个比较通俗的比如,任何一个守护线程都是整个JVM中所有非守护线程的保姆:
  • 只要当前JVM实例中尚存在任何一个非守护线程没有结束,守护线程就全部工作;只有当最后一个非守护线程结束时,守护线程随着JVM一同结束工作。
  • Daemon的作用是为其他线程的运行提供便利服务,守护线程最典型的应用就是 GC (垃圾回收器),它就是一个很称职的守护者。
  • User和Daemon两者几乎没有区别,唯一的不同之处就在于虚拟机的离开:如果 User Thread已经全部退出运行了,只剩下Daemon Thread存在了,虚拟机也就退出了。 因为没有了被守护者,Daemon也就没有工作可做了,也就没有继续运行程序的必要了。
Thread t2=new Thread();  
t2.setDaemon(true);//设置为守护线程  

这里有几点需要注意:

  • thread.setDaemon(true)必须在thread.start()之前设置,否则会跑出一个IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。
  • 在Daemon线程中产生的新线程也是Daemon的。
  • 不要认为所有的应用都可以分配给Daemon来进行服务,比如读写操作或者计算逻辑。
     

因为你不可能知道在所有的User完成之前,Daemon是否已经完成了预期的服务任务。一旦User退出了,可能大量数据还没有来得及读入或写出,计算任务也可能多次运行结果不一样。这对程序是毁灭性的。造成这个结果理由已经说过了:一旦所有User Thread离开了,虚拟机也就退出运行了。 

生产者消费者模型

所谓的生产者消费者模型,是通过一个容器来解决生产者和消费者的强耦合问题。通俗的讲,就是生产者在不断的生产,消费者也在不断的消费,可是消费者消费的产品是生产者生产的,这就必然存在一个中间容器,我们可以把这个容器想象成是一个货架,当货架空的时候,生产者要生产产品,此时消费者在等待生产者往货架上生产产品,而当货架满的时候,消费者可以从货架上拿走商品,生产者此时等待货架的空位,这样不断的循环。那么在这个过程中,生产者和消费者是不直接接触的,所谓的‘货架’其实就是一个阻塞队列,生产者生产的产品不直接给消费者消费,而是仍给阻塞队列,这个阻塞队列就是来解决生产者消费者的强耦合的。就是生产者消费者模型。

此模型主要解决的问题:

  • 生产与消费的速度不匹配
  • 软件开发过程中解耦

新建Goods类

public class Goods {
    private String name;

    public Goods(String name) {
        this.name = name;
    }
}

新建Producer线程

public class Producer implements Runnable{

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (TestDemo.queue) {
                if (TestDemo.queue.size()< TestDemo.MAX_POOL) {
                    TestDemo.queue.add(new Goods("商品"));
                    System.out.println(Thread.currentThread().getName()+"生产商品");
                } else {
                    try {
                        TestDemo.queue.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
}

新建Consumer线程

public class Consumer implements Runnable{
    @Override
    public void run() {
        while (true){
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (TestDemo.queue){
                if(!TestDemo.queue.isEmpty()){
                    // 执行出队列操作
                    TestDemo.queue.poll();
                    System.out.println(Thread.currentThread().getName()+"消费商品");
                }
                else {
                    TestDemo.queue.notify();
                }
            }
        }
    }
}

 测试方法

import java.util.Queue;
import java.util.concurrent.ArrayBlockingQueue;

public class TestDemo {
    public static final int MAX_POOL = 10;
    public static Queue<Goods> queue=new ArrayBlockingQueue<>(MAX_POOL);

    public static void main(String[] args) {
        Producer producer=new Producer();
        Consumer consumer=new Consumer();

        Thread a = new Thread(producer, "生产者01");
        Thread b = new Thread(producer, "生产者02");
        a.start();
        b.start();

        Thread c = new Thread(consumer, "消费者01");
        Thread d = new Thread(consumer, "消费者02");
        c.start();
        d.start();
    }
}

运行结果

使用原子变量实现线程同步

原子操作就是指将读取变量值、修改变量值、保存变量值看成一个整体来操作即-这几种行为要么同时完成,要么都不完成。

原子变量不使用锁或其他同步机制来保护对其值的并发访问。所有操作都是基于cas原子操作的。他保证了多线程在同一时间操作一个原子变量而不会产生数据不一致的错误,并且他的性能优于使用同步机制保护的普通变量。

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicObject {
    private AtomicInteger a = new AtomicInteger(0);

    public void save(Integer value){
        a.addAndGet(value);
    }

    public Integer get(){
        return a.get();
    }

}

总结

以上就是笔者整理的关于Java线程同步和线程锁的一些内容,笔者也通过一些实例让大家更好理解,Java对于线程的开发也引入了一些新的概念,在接下来的文章中会和大家一起分享。希望本文对大家有帮助~~

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值