一文搞清楚java同步

《java网络编程》——java同步

第二章 java 网络编程学习之java同步



前言

在 Java 中,**同步(Synchronization)**是一种用于控制多个线程对共享资源访问的机制,它能有效避免数据竞争和不一致问题,确保程序在多线程环境下正确、稳定地运行。


一、同步

在书本中使用图书馆、图书、借阅者来说明什么是同步、同步的作用。私人书架上有很多书也许会花费上万元,而公共图书馆花费千万元存放了更多的书。两者的区别在于公共图书馆的书由所有居民共享,因此这些书有很高的利用率。每一本书都在一年中会使用很多次,而私人图书架上的书几年都不会再看一次。所以图书馆每本书的成本反而比私人书架低很多。这正是共享资源的优点。
共享资源也存在缺点,当我需要借图书馆的一本书,必须走到图书馆,在书架上寻找需要的书,排队办理借书手续才能借到书,或者只能在图书馆内看而不能带回家。并且有时,别人已经借走这本书,自己就必须填写预约单,申请这本书归还时为自己保留。并且借阅的书不能做任何笔记。所以使用共享资源时,在时间和方便性上会有很大的损失,但可以节约钱和存储空间。
按照上述例子,线程就像图书馆中的借阅者,从一个中心资源池(线程池)中借阅。线程通过共享内存、文件句柄、socket和其他资源使得程序更加高效。只要两个线程不同时使用相同资源,多线程的程序就比多进程程序高效。
多线程程序的缺点是:如果两个线程同时访问一个资源,其中一个就必须等待另一个结束。

使用以下代码示例,将两个线程的打印混在一起。我已经将data.txt复制了一份为data2.txt,为的是让两个计算SHA的时间类似,以来制造冲突,共同共享System.out,看看控制台输出结果。

package com.example.gobuy.utils;

import javax.xml.bind.DatatypeConverter;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class DigestRunnable implements Runnable{
    private String filename;

    public DigestRunnable(String filename){
        this.filename = filename;
    }

    @Override
    public void run(){
        try{
            FileInputStream in = new FileInputStream(filename);
            MessageDigest sha = MessageDigest.getInstance("SHA-256");
            DigestInputStream din = new DigestInputStream(in,sha);
            while (din.read() != -1);
            din.close();
            byte[] digest = sha .digest();
            System.out.print(filename + ": ");
            System.out.print(DatatypeConverter.printHexBinary(digest));
            System.out.println();
            //注释为不共享System.out版本
//            StringBuilder result = new StringBuilder(filename);
//            result.append(": ");
//            result.append(DatatypeConverter.printHexBinary(digest));
//            System.out.println(result);
        }catch (IOException ex){
            System.err.println(ex);
        }catch (NoSuchAlgorithmException ex){
            System.err.println(ex);
        }
    }
    public static void main(String[] args) {
        String[] s = new String[3];
        s[0] = "output.txt";
        s[1] = "data.txt";
        s[2] = "data2.txt";
        for (int i = 0; i < 3; i++) {
            DigestRunnable r = new DigestRunnable(s[i]);
            Thread t = new Thread(r);
            t.start();
        }
    }
}

结果输出

data2.txt: data.txt: CC24732534858B5C55693067F0E83BC3A5E67D483780950F2DE287C1C8E306AD
12981A5D5D602DCFC34E1DAC029D940F42019B5D8D0D8117042EEEB3E1F241BB
output.txt: D1966A1EE8B32860C9F441DE73947F7798CE0EB3225B3CC76A4654433458235A

由结果可以看出,两个文件大小相似的文件输出已经混合在了一起。而第二个output.txt由于文件比较大计算文件SHA值时用时较久,所以其他两个线程已经释放System.out 使得输出并不与他们混合在一起。
在此示例中共享资源是System.out,需要有一种办法能够指定一个共享资源只能由一个线程独占访问执行一个特定的语句序列。需要独占访问的语句是:

System.out.print(filename + ": ");
System.out.print(DatatypeConverter.printHexBinary(digest));
System.out.println();

二、同步块

1.同步块使用

将上述需要独占访问的语句放入同步块

代码如下(示例):

 synchronized (System.out){
                System.out.print(filename + ": ");
                System.out.print(DatatypeConverter.printHexBinary(digest));
                System.out.println();
            }

关于System.out的同步
System.out 是 PrintStream 类的一个实例,许多输出操作会调用它的 println 、print 方法。这些方法本身是同步的,也就意味着,当一个线程在执行 System.out.println() 这类操作时,它获取了与 System.out 关联的锁。如果在另一个类或者不同进程中的代码,也尝试对 System.out 进行同步输出操作,由于锁已经被占用,它们就没办法并行运行,只能等待锁被释放。
不同对象同步或未同步代码的并行性:
不同对象同步的代码块:同步块依靠特定的锁对象来控制并发访问。如果两个同步块使用了不同的锁对象,它们之间是互不干扰的。例如,代码块 A 使用 Object lock1 = new Object(); synchronized (lock1) {… } ,代码块 B 使用 Object lock2 = new Object(); synchronized (lock2) {… },线程访问代码块 A 和代码块 B 时,各自获取对应的锁,所以可以并行运行。
根本不同步的代码:
对于没有使用同步机制的代码,自然不受锁的限制,它们不管有没有涉及到 System.out,都能和同步代码块并行运行。因为这些代码不会去竞争同步代码块所依赖的锁,能自由地在多线程环境下执行各自的任务。

不同的类和不同进程的代码如果也对System.out同步,那么他们不能与这个代码运行

代码示例

class FirstClass {
    public static void printSynced() {
        synchronized (System.out) {
            for (int i = 0; i < 3; i++) {
                System.out.println("FirstClass: " + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

class SecondClass {
    public static void printSynced() {
        synchronized (System.out) {
            for (int i = 0; i < 3; i++) {
                System.out.println("SecondClass: " + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

class Main {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> FirstClass.printSynced());
        Thread thread2 = new Thread(() -> SecondClass.printSynced());

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

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

输出结果

SecondClass: 0
SecondClass: 1
SecondClass: 2
FirstClass: 0
FirstClass: 1
FirstClass: 2

它们各自有一个静态方法 printSynced,都使用 synchronized (System.out) 来同步代码块。这意味着,当一个线程进入FirstClass类中的同步块开始打印时,获取了System.out对应的锁 ,此时如果另一个线程想要进入SecondClass类中的同步块执行打印,由于锁被占用,它就必须等待,所以这两个代码块不能并行运行,输出结果会依次出现,而不是混杂在一起。

只要有多个线程共享资源,都必须考虑同步。这些线程可能是完全不同的类的实例。关键在于这些线程所共享的资源,而不是这些线程是哪个类。只有当两个线程都拥有相同对象的引用时,同步才成为问题,在前面的例子中,问题在于多个线程访问同一个PrintStream对象System.out。导致冲突的是一个静态变量,即使换成实例对象也会有问题。

2.同步方法

java可以向方法声明synchronized,对当前对象(this引用)同步整个方法。

代码如下(示例):

package com.example.gobuy.utils;

public class Counter {

        private int count = 0;

        // 同步方法,用于增加计数器的值
        public synchronized void increment() {  
                count++;
                System.out.println("当前计数值: " + count);
         
        }
        //对对象本身进行同步
        public void increment() {
            synchronized (this){
                count++;
                System.out.println("当前计数值: " + count);
            }
        }
}

调用上述代码,简单使用多线程累加

package com.example.gobuy.utils;

public class CounterMain {
    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                counter.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                counter.increment();
            }
        });

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

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

输出结果

当前计数值: 1
当前计数值: 2
当前计数值: 3
当前计数值: 4
当前计数值: 5
当前计数值: 6
当前计数值: 7
当前计数值: 8
当前计数值: 9
当前计数值: 10

对于同步问题,向所有方法添加synchronized修饰符并不是一劳永逸的解决方案
首先,它会使得VM性能严重下降。其次,它会增加死锁的可能性。第三,并不总是对象本身需要防止同时修改或访问,如果知识对该方法所属类的实例进行同步,并不能保护真正需要保护的对象。
例如上述代码,我们只需要同步累加方法就可以了,而如果同步整个实例对象,如果该类有其他不需要同步的方法,则可能会导致其他不需要同步的实例方法得不到调用。


三、同步的替代方法

  1. 使用局部变量而不是字段,局部变量不存在同步问题,每次进入一个方法时,虚拟机将为这个方法创建一组全新的局部变量。这些变量在方法外是不可见的,而且方法退出时将被撤销。因此,一个局部变量不可能由两个不同的线程共享。每个线程都有自己的局部变量。
  2. 基本类型的方法参数也可以在单独的线程中安全的修改,因为java按值而不是按引用来传递参数。

代码示例


public class ThreadSafeBasic {
   public static void modify(int num) {
       num = num * 2;
   }

   public static void main(String[] args) {
       int number = 10;
       Thread thread1 = new Thread(() -> {
           modify(number);
       });
       Thread thread2 = new Thread(() -> {
           modify(number);
       });

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

       try {
           thread1.join();
           thread2.join();
       } catch (InterruptedException e) {
           e.printStackTrace();
       }

       System.out.println(number);
   }
}

这里main 方法中的 number 初始值是 10,两个线程分别调用 modify 方法,每个线程拿到的都是 number 值 10 的独立副本,它们对各自副本的修改互不干扰,最终 main 方法里的 number 依旧保持初始值 10,不会产生数据竞争等线程不安全问题。这和引用类型形成鲜明对比,引用类型传递的是对象的引用,多个线程如果拿到相同引用,修改对象时就容易引发冲突。

  1. String参数是安全的,因为它们是不可变的。不可变对象永远也不会改变状态,其字段的值只在构造函数运行时设置一次,其后就不会再改变。StringBuffer参数是不安全的,因为它们并不可变,在创建后还可修改。
    在自己类中利用不可变性。使一个类做到线程安全,这是最简单的方法,将其所有字段声明为private和final,而且不要编写任何可能改变它们的方法。核心java库中很多类都是不可变的(java.lang.String、java.lang.Integer、java.lang.Double等)。这使得这些类的功能不强大,但让它们拥有更强的线程安全性。
  2. 将非线程安全的类用作为线程安全的类的一个私有字段。与上一个方法是类似的意思,一个是自己的类中的字段设置为私有,而这个是将一个类放入一个线程安全类并设置为私有字段。
  3. 使用JUC包中设计线程安全但可变的类。例如不使用int而使用AtomicInteger。

总结

以上就是今天要讲的内容,本文仅仅介绍了java同步的知识与使用,也介绍了几个替代java同步的方法,如果觉得不错请点赞收藏关注,我会继续输出java相关的知识!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ZZZ_Tong

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值