线程安全问题

当一个对象的资源同时被多个线程操作(写)时,就可能会出现线程不安全的问题,这时候我们就需要保证线程同步来形成线程安全。处理多线程问题时,多个线程访问同一个对象,并且某些线程还想修改这个对象,这时候我们就需要线程同步。线程同步其实就是一种等待机制,多个需要同时访问,此对象的线程进入这个对象的等待池形成队列,等待前面线程使用完毕,下一个线程再使用,形成线程安全的条件是:队列和锁。实现线程同步的一种方式就是锁机制:当一个线程获得对象的排它锁,独占资源,其他线程必须等待,使用后释放锁即可。但是,使用锁的话不可避免带来一些缺点:

  • 一个线程持有锁会导致其他所有需要此锁的线程挂起;
  • 在多线程竞争下,加锁,释放锁会导致比较多的上下文切换和调度延时,引起性能问题;
  • 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能问题;

加锁解决了安全问题但引起了性能问题,不加锁性能虽然高,但线程不安全。

先看几个线程不安全的例子:

  1. public class UnsafeBank {
  2. public static void main(String[] args) {
  3. Account account = new Account(100, "一起赚的钱");
  4. DrawMoney girlFriend = new DrawMoney(account, 100, "you girlFriend--->");
  5. DrawMoney you = new DrawMoney(account, 50, "you--->");
  6. girlFriend.start();
  7. you.start();
  8. }
  9. }
  10. class Account {
  11. public int money;
  12. public String name;
  13. public Account(int money, String name) {
  14. this.money = money;
  15. this.name = name;
  16. }
  17. }
  18. class DrawMoney extends Thread {
  19. public Account account;
  20. public int drawingMoney;
  21. public String name;
  22. public DrawMoney(Account account, int drawingMoney, String name) {
  23. super(name);
  24. this.account = account;
  25. this.drawingMoney = drawingMoney;
  26. this.name = name;
  27. }
  28. @Override
  29. public void run() {
  30. if (account.money - drawingMoney < 0) {
  31. System.out.println(Thread.currentThread().getName() + "钱不够");
  32. return;
  33. }
  34. System.out.println(Thread.currentThread().getName() + "取钱成功,数量:" + drawingMoney);
  35. account.money = account.money - drawingMoney;
  36. System.out.println("账号余额:" + account.money);
  37. }
  38. }

image.png
可以看到,这个取钱操作明显存在线程安全问题。

然后看一个抢票的例子:

  1. public class UnsafeBuyTicket implements Runnable {
  2. private int ticketNums = 10;
  3. private boolean flag = true;
  4. @Override
  5. public void run() {
  6. while (flag) {
  7. buy();
  8. }
  9. System.out.println("售罄");
  10. }
  11. public void buy() {
  12. if (ticketNums <= 0) {
  13. flag = false;
  14. return;
  15. }
  16. try {
  17. Thread.sleep(1000);
  18. } catch (InterruptedException e) {
  19. e.printStackTrace();
  20. }
  21. System.out.println(Thread.currentThread().getName() + " 买到了:" + ticketNums);
  22. ticketNums--;
  23. }
  24. public static void main(String[] args) {
  25. UnsafeBuyTicket unsafeBuyTicket = new UnsafeBuyTicket();
  26. Thread thread01 = new Thread(unsafeBuyTicket, "黄牛大哥");
  27. Thread thread02 = new Thread(unsafeBuyTicket, "小明同学");
  28. Thread thread03 = new Thread(unsafeBuyTicket, "小美老师");
  29. thread01.start();
  30. thread02.start();
  31. thread03.start();
  32. }
  33. }

image.png
有人买到了同一张票,明显存在线程安全问题。

ArrayList集合是线程不安全的:

  1. public class UnsafeList {
  2. public static void main(String[] args) {
  3. ArrayList<String> list = new ArrayList<>();
  4. for (int i = 0; i < 10000; i++) {
  5. new Thread(() -> {
  6. list.add(Thread.currentThread().getName());
  7. }).start();
  8. }
  9. try {
  10. Thread.sleep(1000);
  11. } catch (InterruptedException e) {
  12. e.printStackTrace();
  13. }
  14. System.out.println(list.size());
  15. }
  16. }

image.png

synchronized关键字

synchronized修饰方法
使用synchronized关键字修饰的方法叫同步方法,可以控制 “对象” 的访问,每个对象对应一把锁,每个synchronized方法都必须获得调用该方法的对象的锁才能执行,否则线程会阻塞,方法或代码块一旦执行,就独占该锁,直到该方法返回才释放,后面被阻塞的线程才能被该方法或代码块获得锁,继续执行。

synchronized修饰代码块
格式:Synchronized(Object obj){
……
}
obj被称为同步监视器,它可以是任何对象,但一般是具有共享资源的对象。同步方法中无需指定同步监视器,因为同步方法的同步监视器就是this,就是这个对象本身,或者是class。同步监视器的执行过程:
第一个线程访问,锁定同步监视器,执行其中代码;
第二个线程访问,发现同步监视器被锁定,无法访问;
第一个线程访问完毕,皆出同步监视器;
第二个线程访问,发现同步监视器没有锁。

上述抢票的例子,由于共享资源对象就是本类,因此只需要锁住抢票的方法即可:

 public synchronized void buy() {
        if (ticketNums <= 0) {
            flag = false;
            return;
        }
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "  买到了:" + ticketNums);
        ticketNums--;
    }

但是上述的银行取钱和线程不安全集合锁着方法却没有用,原因是:
使用synchronized锁住抢钱的run方法是没有用的,因为被修改的共享资源并不是DrawMoney实例对象,而同步方法的同步监视器是this,因此我们需要锁的有共享资源并且被修改的对象,也就是account对象,集合哪个例子也是一样的道理,需要上锁的list,而不是方法。

 @Override
    public void run() {
        synchronized (account){
            if (account.money - drawingMoney < 0) {
                System.out.println(Thread.currentThread().getName() + "钱不够");
                return;
            }
            System.out.println(Thread.currentThread().getName() + "取钱成功,数量:" + drawingMoney);
            account.money = account.money - drawingMoney;
            System.out.println("账号余额:" + account.money);
        }
    }
 public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < 10000; i++) {
            new Thread(() -> {
                synchronized (list){
                    list.add(Thread.currentThread().getName());
                }
            }).start();
        }
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(list.size());
    }

死锁

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。产生死锁的原因,主要包括:

  • 系统资源不足;
  • 程序执行的顺序有问题;
  • 资源分配不当等。

如果系统资源充足,进程的资源请求都能够得到满足,那么死锁出现的可能性就很低;否则,
就会因争夺有限的资源而陷入死锁。其次,程序执行的顺序与速度不同,也可能产生死锁。产生死锁的四个必要条件:

  • 互斥条件:一个资源每次只能被一个进程使用。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。我们要最大可能地避免、预防和解除死锁,在系统设计、进程调度等方面注意如何不让这四个必要条件成立,如何确定资源的合理分配算法,避免进程永久占据系统资源,这就是避免、预防和解决死锁的最佳实践。此外,也要防止进程在处于等待状态的情况下占用资源。因此,对资源的分配要给予合理的规划。
下面看一段死锁代码:

public class DeadLock {
    public static final String LOCK_1 = "lock1";
    public static final String LOCK_2 = "lock2";

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (LOCK_1) {
                System.out.println(Thread.currentThread().getName() + ":锁住-->" + LOCK_1);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (LOCK_2) {
                    System.out.println(Thread.currentThread().getName() + ":锁住-->" + LOCK_2);
                }
            }
        }, "线程1");

        Thread thread2 = new Thread(() -> {
            synchronized (LOCK_2) {
                System.out.println(Thread.currentThread().getName() + ":锁住-->" + LOCK_2);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (LOCK_1) {
                    System.out.println(Thread.currentThread().getName() + ":锁住-->" + LOCK_1);
                }
            }
        }, "线程2");

        thread1.start();
        thread2.start();
    }

}

运行结果:
image.png
可以看到程序无法结束,这是因为产生了死锁:理论上线程1先锁住LOCK_1,休眠1秒后锁住LOCK_2,但是线程2在线程1休眠的时候(或者线程1锁住LOCK_1前)却已经锁住了LOCK_2(所以线程1在等待线程2释放LOCK_2),而线程2也在休眠一秒后想锁住LOCK_1(在等待线程1锁住LOCK_1),就这样互不相让,一直僵持,导致死锁的产生。
如何解决这个死锁:使得线程1和2锁住两个对象的顺序相同即可,或者使得LOCK_1和LOCK_2的值相等,因为字符串有一个常量池,如果不同的线程持有的锁是具有相同字符的字符串锁时,那么两个锁实际上就是同一个锁。

Lock锁

Lock是jdk1.5后新增的一把锁,它有很多的实现类来实现锁的功能,比较常用的实现类是ReentrantLock 类。它是一种显式锁,提供了与synchronized关键字类似的同步功能,只是在使用时需要显式的获取和释放锁,虽然它缺少了隐式获取/释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性;Lock只有代码块锁,Synchronized有代码块锁和方法锁;使用Lock,JVM将花费较少的时间来调度线程,性能更好,并且有更好的拓展性(提供更多的子类);

public class UnsafeBuyTicket implements Runnable {
    private int ticketNums = 10;
    private boolean flag = true;
    private final ReentrantLock lock = new ReentrantLock();

    @Override
    public void run() {
        while (flag) {
            buy();
        }
        System.out.println("售罄");
    }

    public void buy() {
        lock.lock();
        try {
            if (ticketNums <= 0) {
                flag = false;
                return;
            }
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "  买到了:" + ticketNums);
            ticketNums--;
        } finally {
            lock.unlock();
        }

    }

    public static void main(String[] args) {
        UnsafeBuyTicket unsafeBuyTicket = new UnsafeBuyTicket();


        Thread thread01 = new Thread(unsafeBuyTicket, "黄牛大哥");
        Thread thread02 = new Thread(unsafeBuyTicket, "小明同学");
        Thread thread03 = new Thread(unsafeBuyTicket, "小美老师");

        thread01.start();
        thread02.start();
        thread03.start();
    }
}

Synchronied和Lock的区别

  • Synchronied是Java的关键字,Lock是一个接口类;
  • Synchronied无法判断获取锁的状态,而Lock则可以判断是否获取到了锁;
  • Synchronied会自动释放锁,而Lock需要手动释放锁,否则会产生死锁;
  • Synchronied和Lock锁的默认都是可重入锁(非公平锁),但Lock锁可以手动修改;
  • Synchronied中,某个线程获取不到锁会傻傻的等待,而Lock锁则可能不会;
  • Synchronied时候少量代码,Lock锁适合大量代码;

    八锁问题

    8锁问题可加深对锁的理解,参考文章:https://blog.csdn.net/qq_31748587/article/details/105498566

    集合不安全类

    常见的ArrayList、Set、HashMap其实都是线程不安全的,这些都只是适合在单线程的情况下使用的,而在juc中,提供了多线程环境下代替它们的集合:CopyOnWriteArrayList、CopyOnWriteArraySet、CopyOnWriteArraySet。