临界区与锁
并发编程中不可避免的会出现多个线程共享同一个资源的情况, 为了防止出现数据不一致情况的发生, 人们引入了临界区的概念. 临界区是一个用来访问共享资源的代码块, 同一时间内只运行一个线程进入.
那么如何实现这个临界区呢? 这就用到我们的锁了, 当进程想要访问一个临界区时, 它先会去看看是否已经有其他线程进入了, 也就是看是否能获得锁. 如果没有其他线程进入, 那么它就进入临界区, 其他线程就无法进入, 相当于加锁. 反之, 则会被挂起, 处于等待状态, 直到其他线程离开临界区, 且本线程被 JVM 选中才可进入 (因为可能有其他线程也在等待).
利用 Synchronized 解决并发问题
Synchronize 是一个重量级锁, 它会降低程序性能, 因此如果对数据一致性没有要求, 就不要使用它. 如果方法被 Synchronize 关键字声明, 那么该方法的代码块被视为临界区. 当某个线程调用该对象的 synchronized 方法或者访问 synchronized 代码块时, 这个线程便获得了该对象的锁, 其他线程暂时无法访问这个方法, 只有等待这个方法执行完毕或者代码块执行完毕, 这个线程才会释放该对象的锁, 其他线程才能执行这个方法或者代码块.
下面我们将创建两个线程 A,B 来同时访问一个对象: A 从账户里取钱, B 从账户里存钱. 首先是不使用 Synchronized 关键字.
创建账户类
它拥有一个私有变量 balance 表示金额, addAmount 和 subtractAmount 分别对金额执行加减操作.
- public class Account {
- private double balance;
- public double getBalance() {
- return balance;
- }
- public void setBalance(double balance) {
- this.balance = balance;
- }
- public void addAmount(double amount){
- System.out.println("addAmount start");
- double temp=balance;
- try {
- Thread.sleep(100);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- temp+=amount;
- balance=temp;
- System.out.println("addAmount end");
- }
- public void subtractAmount(double amount){
- System.out.println("subtractAmount start");
- double temp=balance;
- try {
- Thread.sleep(100);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- temp-=amount;
- balance=temp;
- System.out.println("subtractAmount end");
- }
- }
创建 A,B 俩线程, 分别对账户存钱和取钱.
- public class A implements Runnable {
- private Account account;
- public A(Account account){
- this.account=account;
- }
- @Override
- public void run() {
- for(int i=0;i<10;i++){
- account.addAmount(1000);
- }
- }
- }
- public class B implements Runnable {
- private Account account;
- public B(Account account){
- this.account=account;
- }
- @Override
- public void run() {
- for(int i=0;i<10;i++){
- account.subtractAmount(1000);
- }
- }
- }
最后在 main 里面测试
- public class Main {
- public static void main(String[] args) {
- Account account=new Account();
- account.setBalance(1000);
- A a=new A(account);
- Thread ThreadA=new Thread(a);
- B b=new B(account);
- Thread ThreadB=new Thread(b);
- System.out.println("Account Balance:"+account.getBalance());
- ThreadA.start();
- ThreadB.start();
- try {
- ThreadA.join();
- ThreadB.join();
- System.out.println("Account Balance:"+account.getBalance());
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
ThreadA 往账户中执行了 10 次存入操作, 每次存入 1000 元, ThreadB 则是以同样的金额执行了 10 次取出操作. 那么按照我们的推测, 最后账户的金额应该维持不变, 但程序的结果却不是我们想要的数字. 这是为什么呢? 因为我们在对数据进行操作的时候, 另外一个线程可能也在进行操作, 逻辑上应该先后执行的方法变成了同时执行, 所以出现了错误.
现在我们给 addAmount 和 subtractAmount 加上 synchronized 关键字, 保证数据一致性, 这样程序就不会出问题了.
如果是使用 synchronize 保护代码块, 则需要将对象引用作为参数传入. 一般来说传入 this 关键字作为引用执行方法的对象就可以了.
锁的到底是什么?
或许在上面的例子你因为粗心只为其中一个方法加了关键字, 那么你会看到这样的现象:
保护代码块要将对象传入, 那应该锁的是对象呀. 你可能会想: 我执行 subtractAmout, 按道理应该等我执行完 addAmount 才能执行, 它都没有 account 这个对象的锁, 不应该在中间插这么一段呀. 但是, 只有加了锁的方法, 线程执行该方法时才会去尝试获得锁, 看看是否有线程进入临界区. 访问非同步方法无需获得锁, 你把 synchronized 去掉跟你只加一个的情况是一样的, 同步方法与非同步遵循的是不同的规则. 也就是说你可以在调用该对象的加了 synchronized 方法的同时, 调用其他的非同步方法.
两个线程怎么同时访问了同一个对象的两个 synchronized 方法?
你可能在捣鼓这个关键字的时候, 惊讶的发现静态方法的与众不同. 如果一个对象中的静态方法用 synchronized 修饰, 那么其他线程可以在该静态方法被访问的同时, 访问该对象中的非静态方法 (当然, 该静态方法同一时间只能被一个线程访问). 换句话说, 两个线程可以同时访问一个对象中的两个 synchronized 方法.
等等, 不是说锁对象吗? 到底锁的是什么? 锁的确实是对象, 但对于静态方法我们说的是 T.class(T 为类名), 非静态方法锁的是 this , 也就是类的实例对象, 两者是不同的.
- class T {
- // 修饰非静态方法
- public synchronized void a() {
- // 临界区
- }
- // 修饰静态方法
- public synchronized static void b() {
- // 临界区
- }
- }
上面那段代码相当于:
- class T {
- // 修饰非静态方法
- public synchronized(this) void a() {
- // 临界区
- }
- // 修饰静态方法
- public synchronized(T.class) static void b() {
- // 临界区
- }
- }
实际上加锁本质就是在锁对象的对象头中写入当前线程 id. 我们可以通过下面的代码验证, 每次都传入 new Object().
- class Account {
- private double balance;
- public synchronized void addAmount(double amount){
- synchronized (new Object()){
- System.out.println("addAmount start");
- double temp=balance;
- try {
- Thread.sleep(100);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- temp+=amount;
- balance=temp;
- System.out.println("addAmount end");
- }
- }
- public void subtractAmount(double amount){
- synchronized (new Object()){
- System.out.println("subtractAmount start");
- double temp=balance;
- try {
- Thread.sleep(100);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- temp-=amount;
- balance=temp;
- System.out.println("subtractAmount end");
- }
- }
- }
因为线程每次调用方法锁的都是新 new 的对象, 所以加锁无效. 甚至编译器可能会将 synchronized 给优化掉, 因为这相当于多把锁保护同一个资源, 编译器一看, 每个人都弄把锁就进来了, 那我还不如不加, 反正都一个样.
另外需要注意的是, synchronized 是可重入锁. 也就是说当线程访问对象的同步方法时, 在调用其他同步方法时无需再去获取其访问权. 因为我们实际上锁的是对象, 对象头里面纪录的都是当前线程的 ID.
总结
修饰函数, 锁的是当前类的实例化对象
修饰静态方法, 锁的是当前类的 Class 对象
修饰同步代码块, 锁的是括号里的对象
加锁实际上就是在锁对象的对象头中写入当前线程 id, 每个线程要想调用这个同步方法, 都会先去锁对象的对象头看看当前线程 id 是不是自己的.
参考
synchronized 锁定的到底是什么?- 知乎 https://www.zhihu.com/question/57794716
来源: https://www.cnblogs.com/AD-milk/p/13253972.html