MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java同步块与方法的使用

2023-10-211.1k 阅读

Java同步块与方法的使用

多线程编程中的同步需求

在Java多线程编程中,当多个线程同时访问和修改共享资源时,可能会引发数据不一致和并发问题。例如,假设有两个线程同时对一个共享的计数器变量进行加1操作。如果没有适当的同步机制,可能会出现一个线程读取了计数器的值,然后另一个线程也读取了相同的值,接着它们都对这个值加1并写回,最终计数器只增加了1而不是2,这就是典型的竞态条件(Race Condition)。

为了解决这类问题,Java提供了同步机制,其中同步块(Synchronized Block)和同步方法(Synchronized Method)是两种重要的同步手段。

同步块(Synchronized Block)

基本概念与语法

同步块是Java中一种实现线程同步的方式,它允许程序员指定一段代码块,只有获得特定对象锁的线程才能进入并执行该代码块。其语法格式如下:

synchronized(lockObject) {
    // 同步代码块
}

这里的lockObject被称为锁对象,任何对象都可以作为锁对象。当一个线程进入同步块时,它会尝试获取lockObject的锁。如果锁可用,线程获得锁并执行同步块中的代码;如果锁不可用,线程会被阻塞,直到锁被释放。

代码示例

假设我们有一个银行账户类BankAccount,其中有一个余额变量balance,多个线程可能会同时对其进行存款和取款操作。为了保证数据一致性,我们可以使用同步块:

public class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    public void deposit(double amount) {
        synchronized (this) {
            balance += amount;
        }
    }

    public void withdraw(double amount) {
        synchronized (this) {
            if (balance >= amount) {
                balance -= amount;
            } else {
                System.out.println("Insufficient funds");
            }
        }
    }

    public double getBalance() {
        synchronized (this) {
            return balance;
        }
    }
}

在上述代码中,depositwithdrawgetBalance方法中都使用了同步块,并且锁对象都是this(即当前BankAccount实例)。这样,当一个线程执行这些方法中的同步块时,其他线程就不能同时进入,从而保证了balance变量的一致性。

锁对象的选择
  1. 使用this作为锁对象:如上述BankAccount示例,使用this作为锁对象非常方便,因为它直接关联到当前对象实例。这种方式适用于需要对当前对象的所有共享资源进行同步的情况。
  2. 使用静态对象作为锁对象:当需要对类级别的共享资源进行同步时,可以使用静态对象作为锁。例如,假设有一个共享的静态计数器:
public class Counter {
    private static int count = 0;
    private static final Object lock = new Object();

    public static void increment() {
        synchronized (lock) {
            count++;
        }
    }

    public static int getCount() {
        synchronized (lock) {
            return count;
        }
    }
}

这里使用了一个静态的lock对象,因为静态方法不能使用this作为锁对象(this指向的是对象实例,而静态方法属于类)。通过使用静态锁对象,不同线程对静态变量count的操作能够得到正确的同步。 3. 使用专门的锁对象:有时候,为了更细粒度的控制同步,我们可以创建专门的锁对象。例如,在一个复杂的类中,某些操作只需要对部分数据进行同步,我们可以为这部分数据创建单独的锁对象:

public class ComplexData {
    private int data1;
    private String data2;
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();

    public void updateData1(int value) {
        synchronized (lock1) {
            data1 = value;
        }
    }

    public void updateData2(String value) {
        synchronized (lock2) {
            data2 = value;
        }
    }
}

这样,对data1data2的操作可以分别同步,提高了并发性能。

同步方法(Synchronized Method)

基本概念与语法

同步方法是另一种实现线程同步的方式,它将整个方法体作为同步代码块。在方法声明中加上synchronized关键字即可,语法如下:

public synchronized void synchronizedMethod() {
    // 方法体
}

对于实例方法,锁对象是this,即当前对象实例;对于静态方法,锁对象是类对象(ClassName.class)。

代码示例

还是以BankAccount类为例,我们可以将方法声明为同步方法:

public class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    public synchronized void deposit(double amount) {
        balance += amount;
    }

    public synchronized void withdraw(double amount) {
        if (balance >= amount) {
            balance -= amount;
        } else {
            System.out.println("Insufficient funds");
        }
    }

    public synchronized double getBalance() {
        return balance;
    }
}

在这个版本中,depositwithdrawgetBalance方法都被声明为同步方法。当一个线程调用其中一个同步方法时,它会自动获取this对象的锁,其他线程不能同时调用该对象的任何同步方法。

同步方法与同步块的比较
  1. 粒度控制
    • 同步块:提供了更细粒度的同步控制。可以只对方法中部分需要同步的代码进行同步,而不是整个方法。例如,在一个包含大量计算但只有一小部分涉及共享资源操作的方法中,使用同步块可以减少同步带来的性能开销,因为只有关键部分被同步。
    • 同步方法:同步粒度是整个方法。如果方法中大部分代码都涉及共享资源操作,使用同步方法比较方便,但如果只有部分代码需要同步,同步方法可能会导致不必要的性能损耗。
  2. 锁对象灵活性
    • 同步块:可以选择任何对象作为锁对象,这使得在复杂的多线程场景中,能够根据实际需求更灵活地进行同步控制。比如,可以针对不同的数据集合使用不同的锁对象,从而提高并发性能。
    • 同步方法:实例方法的锁对象是this,静态方法的锁对象是类对象。这种固定的锁对象选择在某些情况下可能不够灵活,如果需要使用其他对象作为锁,就必须使用同步块。
  3. 代码可读性
    • 同步块:在代码中明确标记出同步的部分,对于理解哪些代码需要同步以及锁对象是什么很有帮助,尤其是在方法体比较长且同步代码只是其中一部分的情况下。
    • 同步方法:从方法声明上就表明该方法是同步的,代码看起来更简洁,对于简单的同步需求,可读性较好。但如果方法体较长且同步部分不明显,可能会增加理解的难度。

同步机制的本质

在Java中,同步机制的本质是基于监视器(Monitor)模型。每个对象都关联一个监视器,当一个线程进入同步块或同步方法时,它实际上是在请求获取对象关联的监视器的锁。

当线程获取到锁后,它就进入了监视器的进入区(Entry Set),并开始执行同步代码。在同步代码执行期间,其他线程如果尝试获取相同对象的锁,就会被放入等待队列(Wait Set)中,直到当前线程释放锁。当前线程执行完同步代码或抛出异常时,会释放锁,等待队列中的一个线程会被唤醒并尝试获取锁。

这种基于监视器的同步机制确保了在同一时刻,只有一个线程能够进入同步区域,从而保证了共享资源的一致性。

死锁问题与避免

  1. 死锁的概念:死锁是多线程编程中一种严重的问题,当两个或多个线程相互等待对方释放锁,导致所有线程都无法继续执行时,就会发生死锁。例如,假设有两个线程ThreadAThreadBThreadA持有锁lock1并试图获取lock2,而ThreadB持有锁lock2并试图获取lock1,这样就形成了死锁。
  2. 死锁示例
public class DeadlockExample {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("ThreadA acquired lock1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("ThreadA acquired lock2");
                }
            }
        });

        Thread threadB = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("ThreadB acquired lock2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println("ThreadB acquired lock1");
                }
            }
        });

        threadA.start();
        threadB.start();
    }
}

在上述代码中,ThreadA先获取lock1,然后试图获取lock2ThreadB先获取lock2,然后试图获取lock1,这就导致了死锁。 3. 避免死锁的方法: - 破坏死锁的四个必要条件: - 互斥条件:某些资源只能被一个线程占用,这个条件在大多数情况下是无法避免的,因为共享资源本身的特性决定了需要互斥访问。 - 占有并等待条件:可以通过一次性获取所有需要的锁来避免。例如,在上述死锁示例中,如果ThreadAThreadB都先获取lock1,再获取lock2,就不会发生死锁。 - 不可剥夺条件:在Java中,锁一旦被一个线程获取,其他线程不能强制剥夺。但可以通过超时机制来模拟剥夺。例如,使用ReentrantLocktryLock(long timeout, TimeUnit unit)方法,在指定时间内获取锁,如果超时则放弃,从而避免无限等待。 - 循环等待条件:可以通过对锁进行排序,所有线程按照相同的顺序获取锁来避免。比如,按照锁对象的哈希值从小到大的顺序获取锁。 - 使用超时机制:如前面提到的ReentrantLocktryLock方法,在多线程竞争锁的场景中,设置一个合理的超时时间。如果在超时时间内没有获取到锁,线程可以选择放弃或进行其他操作,从而避免死锁。 - 检测与恢复:可以定期检测系统中是否存在死锁,一旦检测到死锁,可以通过日志记录等方式通知开发人员,或者采取一些恢复措施,例如重启相关线程或进程。在Java中,可以使用ThreadMXBean来检测死锁,它提供了方法来获取可能发生死锁的线程信息。

性能优化与注意事项

  1. 性能优化
    • 减少同步粒度:尽量使用同步块而不是同步方法,只对真正需要同步的代码进行同步,避免不必要的同步开销。例如,在一个包含大量计算和少量共享资源操作的方法中,将共享资源操作部分放入同步块中。
    • 避免锁竞争:合理设计程序结构,减少多个线程同时竞争同一把锁的情况。可以通过使用不同的锁对象来分离不同的数据操作,提高并发性能。例如,在一个包含多个数据集合的类中,为每个数据集合使用单独的锁对象。
    • 使用更高效的同步工具:在Java 5.0之后,引入了java.util.concurrent包,其中包含了一些更高效的同步工具,如ReentrantLockSemaphoreCountDownLatch等。这些工具在某些场景下比传统的synchronized关键字更灵活且性能更好。例如,ReentrantLock提供了可中断的锁获取、公平锁机制等功能,在高并发且对公平性有要求的场景中更适用。
  2. 注意事项
    • 锁的可重入性:在Java中,synchronized关键字修饰的同步块和同步方法都是可重入的。这意味着一个线程可以多次获取同一把锁,而不会造成死锁。例如,一个同步方法调用另一个同步方法,且这两个方法使用相同的锁对象,线程可以顺利执行。但在使用自定义锁(如ReentrantLock)时,也要注意其可重入性的特点,正确使用锁的获取和释放操作。
    • 避免锁泄漏:在使用锁时,一定要确保在合适的地方释放锁。在同步块中,如果发生异常,要确保锁能够被正确释放,否则可能会导致其他线程永远无法获取锁。例如,在使用try - finally块来保证锁的释放:
Lock lock = new ReentrantLock();
try {
    lock.lock();
    // 同步代码
} finally {
    lock.unlock();
}
- **同步与可见性**:除了保证数据的一致性,同步机制还能保证内存的可见性。当一个线程修改了共享变量并释放锁时,其他线程在获取锁后能够看到这个修改。但要注意,在多线程环境下,即使使用了同步,如果对共享变量的操作不符合happens - before原则,也可能出现可见性问题。例如,在使用双重检查锁定(Double - Checked Locking)实现单例模式时,如果不使用`volatile`关键字修饰单例实例,可能会出现可见性问题。

综上所述,Java的同步块和同步方法是多线程编程中保证数据一致性和线程安全的重要手段。通过深入理解它们的原理、使用方法以及性能优化和注意事项,可以编写出高效、健壮的多线程程序。在实际应用中,需要根据具体的业务需求和场景,合理选择同步机制,并注意避免死锁等问题,以充分发挥多线程编程的优势。