锁和synchronized

10次阅读

共计 3796 个字符,预计需要花费 10 分钟才能阅读完成。

锁的常见概念

  • 互斥: 同一时刻只有一个线程执行
  • 临界区: 一段需要互斥执行的代码
  • 细粒度锁: 用不同的锁对受保护资源进行精细化管理。细粒度锁可以提高并行度, 是性能优化的一个重要手段
  • 死锁:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。

用锁的最佳实践

  1. 永远只再更新对象的成员变量时加锁。
  2. 永远只在访问可变的成员变量时加锁。
  3. 永远不再调用其它对象的方法时加锁。
  4. 减少所得持有时间,减小锁的粒度。

同步与异步

  • 调用方法如果需要等待结果,就是同步;如果不需要等待结果就是异步。
  • 同步是 Java 代码默认的处理方式。

如何实现程序支持异步:

  1. 异步调用: 调用方创建一个子线程,再子线程中执行方法调用。
  2. 异步方法: 被调用方;方法实现的时候,创建一个显得线程执行主要逻辑,主线程直接 return。

synchronized

class  X{
    // 修饰非静态方法
    synchronized void foo(){// 临界区}
    // 修饰静态方法
    synchronized static void bar(){// 临界区}
    
    // 修饰代码块
    Object obj = new Object();
    void baz(){synchronized(obj){// 临界区}
    }
}

Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock()和解锁 unlock(),这样做的好处就是加锁 lock()和解锁 unlock()一定 是成对出现的,毕竟忘记解锁 unlock()可是个致命的 Bug(意味着其他线程只能死等下去了)。

修饰静态方法:
// 修饰静态方法是用当前类的字节码文件作为锁
class  X{
    // 修饰静态方法
    synchronized(X.class) static void bar(){// 临界区}
}
修饰非静态方法:
// 修饰非静态方法是用当前对象作为锁
class  X{
    // 修饰非静态方法
    synchronized(this) static void bar(){// 临界区}
}

如何用一把锁保护多个资源

受保护资源和锁之间合理的关联关系应该是 N:1 的关系,也就是说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源,

使用锁的正确姿势

依转账业务作为示例

示例一:

public class Account {
    /**
     * 锁:保护账⼾余额
     */
    private    final    Object    balLock    = new Object();
    /**
     * 账⼾余额
     */
    private    Integer    balance;
   
    /**
     * 错误的做法
     * 非静态方法的锁是 this, 
     * this 这把锁可以保护自己的余额 this.balance,保护不了别人的余额 target.balance
     * 
     */
   synchronized void transfer(Account target,int amt){if (this.balance > amt) {
            this.balance -= amt;
            target.balance += amt;// 这段代码会出现线程安全, 要保证线程安全的话要使用同一个锁
        }
    }
}

示例二:

public class Account {
    /**
     * 锁:保护账⼾余额
     */
    private    final    Object    balLock    = new Object();
    /**
     * 账⼾余额
     */
    private    Integer    balance;
   

    /**
     * 正确的做法,但是会导致整个转账系统的串行
     *
     * Account.class 是所有 Account 对象共享的,* 而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,* 所以我们不用担心它的唯一性
     *
     * 这样还有个弊端: 所有的转账都是串行了
     */
    void transfer2(Account target,int amt){synchronized(Account.class){if (this.balance > amt) {
                this.balance -= amt;
                target.balance += amt;
            }
        }
    }
}

这样的话转账操作就成了串行的了,正常的逻辑应该只锁转入账号和被转入账户;不影响其他的转账操作。稍作改造:

示例三:

public class Account {
    /**
     * 锁:保护账⼾余额
     */
    private    final Object lock;
    /**
     * 账⼾余额
     */
    private    Integer    balance;
   
    // 私有化无参构造
    private Account(){}
    // 设置一个传递 lock 的有参构造
    private Account(Object lock){this.lock = lock;}
    
    /**
     * 转账
     */
    void transfer(Account target,int amt){
        // 此处检查所有对象共享锁
        synchronized(lock){if (this.balance > amt) {
                this.balance -= amt;
                target.balance += amt;
            }
        }
    }
}

这个方法虽然能够解决问题, 但是它要求创建 Account 对象的时候必须传入同一个对象,

还有就是传递对象过于麻烦, 写法繁琐缺乏可行性。

示例四:

public class Account {
    
    /**
     * 账⼾余额
     */
    private    Integer    balance;
    
    /**
     * 转账
     */
    void transfer(Account target,int amt){
        // 此处检查所有对象共享锁
        synchronized(Account.class){if (this.balance > amt) {
                this.balance -= amt;
                target.balance += amt;
            }
        }
    }
}

用 Account.class 作为共享的锁, 锁定的范围太大。Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。使用 Account.class 作为共享的锁,我们就无需在创建 Account 对象时传入了。

这样新的问题就出来了虽然用 Account.class 作为互斥锁,来解决银行业务里面的转账问题,虽然这个方案不存在 并发问题,但是所有账户的转账操作都是串行的,例如账户 A 转账户 B、账户 C 转账户 D 这两个转账操作现实 世界里是可以并行的,但是在这个方案里却被串行化了,这样的话,性能太差。所以如果考虑并发量这种方法也不行的

正确的写法是这样的(使用细粒度锁):

示例五:

public class Account {
    
    /**
     * 账⼾余额
     */
    private    Integer    balance;
    
    /**
     * 转账
     */
    void transfer(Account target,int amt){
        // 锁定转出账户
        synchronized(this){
             // 锁住转入账户
            synchronized(target){if (this.balance > amt) {
                    this.balance -= amt;
                    target.balance += amt;
                }
            }
        }
    }
}

我们试想在古代,没有信息化,账户的存在形式真的就是一个账本,而且每个账户都有一个账本,这些账本 都统一存放在文件架上。银行柜员在给我们做 转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。这个柜员在拿账本的时候可能遇到以下三种情况:

  1. 文件架上恰好有转出账本和转入账本,那就同时拿走;
  2. 如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其 他柜员把另外一个账本送回来;
  3. ​ 转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。

细粒度锁有可能会出现死锁

  • 死锁:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
  • 两个线程彼此拿着对方的资源都不释放就会导致死锁,
  • 使用细粒度锁可能会导致死锁

如果有客户找柜员张三做个转账业务:账户 A 转账户 B 100 元,此时另一个客户找柜员李四也做个转账业务:账户 B 转账户 A 100 元,于是张三和李四同时都去文件架上拿账本,这时候有可能凑巧张三拿到了账本 A,李四拿到了账本 B。张三拿到账本 A 后就等着 账本 B(账本 B 已经被李四拿走),而李四拿到账本 B 后就等着账本 A(账本 A 已经被张三拿走),他们要等 多久呢?他们会永远等待下去…因为张三不会把账本 A 送回去,李四也不会把账本 B 送回去。我们姑且称为死等吧。

如何避免死锁
  1. 互斥, 共享资源 X 和 Y 只能被一个线程占用;
  2. 占有且等待, 线程 T1 已经取得共享资源 X, 在等待共享资源 Y 的时候, 不释放共享资源 x;
  3. 不可抢占, 其他线程不能强行抢占线程 T1 占有的资源;
  4. 循环等待, 线程 1 等待线程 T2 占有的资源, 线程 T2 等待线程 T1 占有的资源, 就是循环等待。

只要破坏其中一个就可以避免死锁

等待 - 通知机制

用 synchronized 实现等待 - 通知机制

  • synchronized 配合 wait(),notif(),notifyAll()这三个方法能够轻松实现.
  • wait(): 当前线程释放锁, 进入阻塞状态
  • notif(),notifAll(): 通知阻塞的线程有可以继续执行, 线程进入可执行状态
  • notif()是会随机地地通知等待队歹一个线程
  • notifyAll()会通知等待队列中的所有线程, 建议使用 notifAll()

wait 与 sleep 区别:

sleep 是 Object 的中的方法,wait 是 Thread 中的方法

wait 会释放锁,sleep 不会释放锁

wait 需要用 notif 唤醒,sleep 设置时间, 时间到了唤醒

wait 无需捕获异常, 而 sleep 需要

wait(): 当前线程进入阻塞


码字不易如果对你有帮助请给个关注

爱技术爱生活 QQ 群: 894109590

正文完
 0