• JUC——并发编程—第四部分


    理解JMM

    Volatile是Java虚拟机提供的轻量级的同步机制。有三大特性。

    1.保证可见性

    2.不保证原子性

    3.禁止指令重排

    定义:Java内存模型,是一个概念。

    关于JMM的一些同步的约定:

    1、线程解锁前,必须把共享变量立刻刷回主存.

    2、线程加锁前,必须读取主存中的最新值到工作内存中!

    3、加锁和解锁是同一把锁。

    线程工作内存和主内存

    这里面涉及到8个操作。线程A将变量flag从主存读取出来是read,加载到自己的工作内存然后执行引擎使用工作内存里的flag,用完放回工作内存,解锁前把工作内存里面的变量刷回主存。

    问题:

    关于主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存。如何从工作内存同步到主内存中的实现细节。java内存模型定义了8种操作来完成。这8种操作每一种都是原子操作。8种操作如下:

    • lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;

    • read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;

    • load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;

    • use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;

    • assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;

    • store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;

    • write(写入):作用于主内存,它把store传送值放到主内存中的变量中。

    • unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定;

    Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:

    (1)不允许read和load、store和write操作之一单独出现(即不允许一个变量从主存读取了但是工作内存不接受,或者从工作内存发起会写了但是主存不接受的情况),以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。

    (2)不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

    (3)不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。

    (4)一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。

    (5)一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

    (6)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

    (7)如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。

    (8)对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)。

    演示代码

    1. public class JMMdemo{
    2. private static int num=0;
    3. public static void main(String[] args) throws InterruptedException {
    4. new Thread(()->{
    5. while(num==0){
    6. }
    7. System.out.println("t退出了");
    8. }).start();
    9. TimeUnit.SECONDS.sleep(1);
    10. num=1;
    11. System.out.println(num);
    12. }
    13. }

    在上面代码里面,主线程更改了num的值之后成功写会主线程,但是,子线程不知道,所以这里就有问题了。需要让子线程知道主内存的值被修改。 

    Volatile可见性及非原子性验证

    保证可见性

    这里只需要加一个Volatile关键字就可以了。 

    加完后发现成功退出死循环。  

    非原子性

    原子性:不可分割

    事务就具有原子性,要么同成功,要么同失败。

    1. public class demo02 {
    2. //不能保证原子性的,即结果不是20000
    3. private volatile static int num=0;
    4. private static Lock lock=new ReentrantLock();
    5. //要么用Lock锁,要么用synchronized都可以保证。
    6. public static void add(){
    7. // lock.lock();
    8. num++;
    9. // lock.unlock();
    10. }
    11. public static void main(String[] args) {
    12. //理论为20000,实际不是
    13. for(int i=0;i<20;i++){
    14. new Thread(()->{
    15. for(int j=0;j<1000;j++) {
    16. add();
    17. }
    18. }).start();
    19. }
    20. while(Thread.activeCount()>2){ //确保20条线程都运行完,只剩main和gc
    21. Thread.yield(); //线程礼让
    22. }
    23. System.out.println(num);
    24. }
    25. }

    使用原子类解决原子性问题,消耗资源没有那两个大。 

    1. public class demo02 {
    2. //不能保证原子性的,即结果不是20000
    3. //原子类的Integer
    4. private volatile static AtomicInteger num=new AtomicInteger();
    5. private static Lock lock=new ReentrantLock();
    6. //要么用Lock锁,要么用synchronized都可以保证。
    7. public static void add(){
    8. // lock.lock();
    9. // num++; //不是原子性操作
    10. // lock.unlock();
    11. num.getAndDecrement();// +1方法.底层用的CAS
    12. }
    13. public static void main(String[] args) {
    14. //理论为20000,实际不是
    15. for(int i=0;i<20;i++){
    16. new Thread(()->{
    17. for(int j=0;j<1000;j++) {
    18. add();
    19. }
    20. }).start();
    21. }
    22. while(Thread.activeCount()>2){ //确保20条线程都运行完,只剩main和gc
    23. Thread.yield(); //线程礼让
    24. }
    25. System.out.println(num);
    26. }
    27. }

     里面涉及到的一个Unsafe类是一个很特殊的存在。

    指令重排详解

    定义:写的源代码在变成目标代码之前会进行一个代码优化。这就涉及到重排。

    在保证结果正确的前提下进行指令重排。

    可能造成的影响结果。

    线程A线程B
    x=ay=b
    b=1a=2

     一开始x,y,a,b都是0,正常结果应该是x=0,y=0.

    两个线程进行指令重排之后可能会变成这样

    线程A线程B
    b=1a=2
    x=ay=b

    对于线程A来说命令顺序不重要,所以有可能会打乱。

    结果变成:x=2,y=1.

    加了volatile之后就会避免指令重排了。

    CPU中有一个内存屏障。

    作用:

    1、保证特定的操作的执行顺序 !

    2、可以保证某些变量的内存可见性!(利用这些特性volatile实现了可见性 )

    内存屏障在单例模式使用最多。

    彻底玩转单例模式

    饿汉式单例

    饿汉式(Eager Initialization)单例模式: 饿汉式单例模式是在类加载时就创建实例对象,无论是否需要。这意味着在程序运行时,单例实例会立即被创建。饿汉式的实现简单,但可能会浪费内存,因为即使在某些情况下没有使用单例对象,它也会被创建。

    1. 在下列代码中,instance 是在类加载时创建的,因此它是一个饿汉式单例。

    1. /**
    2. * 饿汉式单例
    3. */
    4. public class Hungry {
    5. //没有使用的话可能会浪费空间。
    6. private byte[] data1=new byte[1024*1024];
    7. private byte[] data2=new byte[1024*1024];
    8. private byte[] data3=new byte[1024*1024];
    9. private byte[] data4=new byte[1024*1024];
    10. private Hungry(){
    11. }
    12. private final static Hungry Hungry=new Hungry();
    13. public static Hungry getInstance(){
    14. return Hungry;
    15. }
    16. }

    DCL懒汉式单例

    懒汉式(Lazy Initialization)单例模式: 懒汉式单例模式是在首次需要时才创建实例对象。这种方式可以避免不必要的内存消耗,但需要注意线程安全性,因为在多线程环境中,多个线程可能同时尝试创建实例。为了确保线程安全,可以使用双重检查锁定(Double-Checked Locking,DCL)来延迟初始化,如下所示:

    1. public class LazySingleton {
    2. private static volatile LazySingleton instance;
    3. private LazySingleton() { }
    4. public static LazySingleton getInstance() {
    5. if (instance == null) {
    6. synchronized (LazySingleton.class) {
    7. if (instance == null) {
    8. instance = new LazySingleton();
    9. }
    10. }
    11. }
    12. return instance;
    13. }
    14. }

    反射破坏单例

    懒汉式里面会等到用到时才创建,在多线程下会破坏单例,可以使用双重检测锁模式的懒汉式单例。但是万一有指令重排的话还会有别的问题。

    /**
     * new的过程
     * 1.分配内存空间
     * 2.执行构造方法,初始化对象
     * 3.把这个对象指向该空间
     *
     * 123
     * 132 A 线程指令重排
     *     B 进来后发现不为null了,但是实际还没完成构造,会直接返回一个null
     */

    并且这里可以用反射机制破解单例模式,成功创建出两个实例。

    但是可以直接锁住class对象避免反射破坏,但是这样会有三重检测。

    1. /**
    2. * 懒汉式单例模式
    3. */
    4. public class Lazyman {
    5. private Lazyman(){
    6. synchronized (Lazyman.class){
    7. if(lazyman!=null)
    8. throw new RuntimeException("不要使用反射破坏单例模式");
    9. }
    10. }
    11. private volatile static Lazyman lazyman;
    12. //双重检测锁模式的懒汉式单例,简称DCL
    13. public static Lazyman getInstance(){
    14. if(lazyman==null) {
    15. synchronized (Lazyman.class){
    16. if(lazyman==null) {
    17. lazyman = new Lazyman(); //非原子性操作,
    18. }
    19. }
    20. }
    21. return lazyman;
    22. }
    23. public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
    24. Lazyman instance=Lazyman.getInstance();
    25. Lazyman instance2=Lazyman.getInstance(); //两个获得的都是同一个实例
    26. //下面利用反射无视私有的构造器
    27. Constructor declaredConstructor = Lazyman.class.getDeclaredConstructor(null);
    28. declaredConstructor.setAccessible(true); //破坏私有权限
    29. Lazyman lazyman1 = declaredConstructor.newInstance();//调用默认无参构造方法
    30. System.out.println(instance);
    31. System.out.println(instance2);
    32. System.out.println(lazyman1);
    33. }
    34. /**
    35. * new的过程
    36. * 1.分配内存空间
    37. * 2.执行构造方法,初始化对象
    38. * 3.把这个对象指向该空间
    39. *
    40. * 123
    41. * 132 A 线程指令重排
    42. * B 进来后发现不为null了,但是实际还没完成构造,会直接返回一个null
    43. */
    44. }

    虽然但是,这里还是可以使用构造器调用私有的默认构造方法来创建两个不同的实例且不会报错,只要不调用它的getInstance方法即可。private volatile static Lazyman lazyman;就会一直为空。

    解决方法:用一个新的变量

    1. /**
    2. * 懒汉式单例模式
    3. */
    4. public class Lazyman {
    5. private static boolean qinjiang=false;
    6. private Lazyman(){
    7. synchronized (Lazyman.class){
    8. if(qinjiang==false) {
    9. qinjiang=true;
    10. }else{
    11. throw new RuntimeException("不要使用反射破坏单例模式");
    12. }
    13. }
    14. }
    15. private volatile static Lazyman lazyman;
    16. //双重检测锁模式的懒汉式单例,简称DCL
    17. public static Lazyman getInstance(){
    18. if(lazyman==null) {
    19. synchronized (Lazyman.class){
    20. if(lazyman==null) {
    21. lazyman = new Lazyman(); //非原子性操作,
    22. }
    23. }
    24. }
    25. return lazyman;
    26. }
    27. public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
    28. // Lazyman instance=Lazyman.getInstance();
    29. // Lazyman instance2=Lazyman.getInstance(); //两个获得的都是同一个实例
    30. //下面利用反射无视私有的构造器
    31. Constructor declaredConstructor = Lazyman.class.getDeclaredConstructor(null);
    32. declaredConstructor.setAccessible(true); //破坏私有权限
    33. Lazyman lazyman1 = declaredConstructor.newInstance();
    34. Lazyman lazyman2 = declaredConstructor.newInstance();
    35. System.out.println(lazyman1);
    36. System.out.println(lazyman2);
    37. }
    38. }

    但是还有反转,如果可以知道有一个qinjiang的变量可以通过破坏私有权限修改它的值。照样可以破坏其单例模式。这里就不给代码了。

     枚举安全

    在其源码里面可以看见如果是枚举类型会说不能使用反射破坏枚举对象。

    没有无参构造,只有有参构造

    1. //Enum本身也是一个class类
    2. public enum Enumsigle{
    3. INSTANCE;
    4. public Enumsigle getInstance(){
    5. return INSTANCE;
    6. }
    7. }
    8. class Test{
    9. public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
    10. // Enumsigle enumsigle1= Enumsigle.INSTANCE;
    11. // Enumsigle enumsigle2= Enumsigle.INSTANCE;
    12. // System.out.println(enumsigle1);
    13. // System.out.println(enumsigle2); //输出同一个实例
    14. Enumsigle enumsigle1= Enumsigle.INSTANCE;
    15. Constructor declaredConstructor = Enumsigle.class.getDeclaredConstructor(null);
    16. declaredConstructor.setAccessible(true);
    17. Enumsigle enumsigle2 = declaredConstructor.newInstance();
    18. System.out.println(enumsigle1);
    19. System.out.println(enumsigle2);
    20. }
    21. }

     这里会报错没有这个空参构造方法.但是idea里面是可以看见有的

    经过反编译之后可以看见也是有这个空参构造的。

    使用jad生成的文件可以看见有一个有参构造

     加上参数之后可以看见正确的报错

    1. //Enum本身也是一个class类
    2. public enum Enumsigle{
    3. INSTANCE;
    4. public Enumsigle getInstance(){
    5. return INSTANCE;
    6. }
    7. }
    8. class Test{
    9. public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
    10. // Enumsigle enumsigle1= Enumsigle.INSTANCE;
    11. // Enumsigle enumsigle2= Enumsigle.INSTANCE;
    12. // System.out.println(enumsigle1);
    13. // System.out.println(enumsigle2); //输出同一个实例
    14. Enumsigle enumsigle1= Enumsigle.INSTANCE;
    15. Constructor declaredConstructor = Enumsigle.class.getDeclaredConstructor(String.class,int.class);
    16. declaredConstructor.setAccessible(true);
    17. Enumsigle enumsigle2 = declaredConstructor.newInstance();
    18. System.out.println(enumsigle1);
    19. System.out.println(enumsigle2);
    20. }
    21. }

     雀氏知道了反射不能破坏枚举的单例模式。

    深入理解CAS

    CAS:比较当前工作内存中的值和主内存中的值,如果这个值是期望的,那么执行操作,否则不执行。如果不是就一直循环

    CAS 是一种基于内存值的比较和条件交换的操作,它通常用于实现线程安全的数据结构和算法。CAS 操作包括三个主要步骤:

    1. 读取内存值:首先,CAS 操作会读取一个共享变量的当前值。

    2. 比较值:接下来,CAS 操作会比较读取到的值与预期的值是否相等。如果相等,表示共享变量的值没有被其他线程修改,可以继续执行下一步。如果不相等,CAS 操作将失败,不会执行后续步骤。

    3. 条件交换:如果比较成功,CAS 操作会尝试将共享变量的值修改为新的值。这一步是原子操作,意味着在这一步中,如果有其他线程尝试修改共享变量,它们会失败并且不会覆盖新值。

    CAS 的主要优点是它是非阻塞的,这意味着它不会使线程陷入阻塞等待其他线程完成操作。它通过循环重试来实现,直到成功为止。这使得 CAS 成为一种高效的多线程同步机制

    在 Java 中,java.util.concurrent 包中的一些类,如 AtomicIntegerAtomicLongAtomicReference,使用 CAS 操作来实现线程安全的原子操作。此外,JVM 和 Java 编程语言的规范也使用了 CAS 来定义内存可见性和线程同步的行为,以确保多线程程序的正确性。

    1. public class CASDemo {
    2. //CAS compareAndSet比较并交换
    3. public static void main(String[] args) {
    4. AtomicInteger atomicInteger=new AtomicInteger(2022);//底层用了CAS
    5. //期望,更新
    6. // public final boolean compareAndSet(int expect, int update)
    7. //如果期望的值达到了,就更新,否则不更新,CAS是CPU的并发原语!
    8. System.out.println(atomicInteger.compareAndSet(2022, 2023));
    9. System.out.println(atomicInteger.get());
    10. System.out.println(atomicInteger.compareAndSet(2022, 2023));
    11. }
    12. }

    缺点:

    1.循环会耗时

    2.一次只能保证一个共享变量的原子性

    3.ABA问题 

    unsafe方法

    在AtomicInteger原子类的底层自增的方法是如下操作,会调用一个unsafe的compareAndSwapInt

     

    CAS的ABA问题(狸猫换太子(乐观锁思想))

    CAS(Compare-and-Swap)是一种原子操作,通常用于多线程编程中实现并发控制。CAS 操作包括读取一个共享变量的当前值,比较它与预期值,如果相等则执行更新操作,否则不做任何操作。CAS 通常用于实现无锁算法和数据结构。

    问题中的 "ABA" 指的是一个特定的并发问题,它涉及到如下情况:

    1. 线程 A 读取一个共享变量的值为 A。
    2. 线程 B 修改该共享变量的值为 B。
    3. 线程 C 修改该共享变量的值再次改回 A。

    从线程 A 的角度来看,它在执行 CAS 操作时读取的共享变量值仍然是 A,因此 CAS 操作成功,尽管实际上共享变量的值在此期间已经经历了改变。这就是 "ABA 问题" 的本质,即虽然共享变量的值经历了 A -> B -> A 的变化,但线程 A 并未察觉到这一点。

    ABA 问题可能导致意外行为和错误,特别是在需要确保数据的一致性和正确性的情况下。为了解决 ABA 问题,通常需要在 CAS 操作中引入版本号或标记,以确保只有在预期值匹配的情况下才执行更新操作。这可以通过引入额外的字段,如版本号,来实现。

    总之,ABA 问题是与 CAS 操作相关的一个并发问题,它需要特殊的处理来避免影响程序的正确性。解决方法通常包括引入版本号或标记来增加 CAS 操作的安全性。

    版本号?这不就是乐观锁吗?

    原子引用

    例如,Java 中的 java.util.concurrent.atomic 包中的原子类,如 AtomicStampedReferenceAtomicMarkableReference,就是为了解决 ABA 问题而设计的,它们在 CAS 操作中包含了版本号或标记,以确保 CAS 操作能够正确地检测到变化。

    在原子引用中的包装类问题

    **Integer 使用了对象缓存机制,默认范围是-128~ 127,推荐使用静态工厂方法 valueof 获取对象实例,而不是 new因为 valueof 使用缓存,而 new 一定会创建新的对象分配新的内存空间;**

    包装类有毒,包装类不存在引用,只是重新创建。

    一般泛型比较的都是一个对象,对象的话就都是唯一的。 

    1. public class CASDemo {
    2. public static void main(String[] args) {
    3. //z注意: 如果泛型是包装类,注意对象的引用问题
    4. AtomicStampedReference integerAtomicReference = new AtomicStampedReference<>(1,1);
    5. //期望,更新
    6. new Thread(()->{
    7. int stamp=integerAtomicReference.getStamp();
    8. System.out.println("a1=>"+stamp);
    9. System.out.println(integerAtomicReference.compareAndSet(1, 2,
    10. integerAtomicReference.getStamp(), integerAtomicReference.getStamp() + 1));//版本号+1
    11. System.out.println("a2=>"+integerAtomicReference.getStamp());
    12. //再改回去
    13. System.out.println(integerAtomicReference.compareAndSet(2, 1,
    14. integerAtomicReference.getStamp(), integerAtomicReference.getStamp() + 1));//版本号+1
    15. System.out.println("a3=>"+integerAtomicReference.getStamp());
    16. },"a").start();
    17. new Thread(()->{
    18. int stamp=integerAtomicReference.getStamp();
    19. System.out.println("b1=>"+integerAtomicReference.getStamp());
    20. try {
    21. TimeUnit.SECONDS.sleep(2);
    22. } catch (InterruptedException e) {
    23. throw new RuntimeException(e);
    24. }
    25. System.out.println(integerAtomicReference.compareAndSet(1, 5,
    26. stamp, stamp + 1));//版本号+1
    27. System.out.println("b2=>"+integerAtomicReference.getStamp());
    28. },"b").start();
    29. }
    30. }

    执行结果如下,现在成功解决了ABA问题

     各种锁的理解

    1、公平锁、非公平锁

    公平锁 : 非常公平,不能够插队,必须先来后到 !

    非公平锁 : 非常不公平,可以插队(Locksynchronized默认都是用的非公平)

    Lock lock = new ReentrantLock(true); // 创建一个公平锁
    

    2.可重入锁

    可重入锁(Reentrant Lock),也称为递归锁,是一种支持同一个线程多次获取同一个锁的锁机制。这意味着如果一个线程已经获得了某个锁,那么它可以多次再次获取该锁,而不会被阻塞。可重入锁允许线程在持有锁的情况下多次进入由这个锁保护的临界区域,而不会引发死锁或其他问题。

    Java 中的 ReentrantLocksynchronized 关键字都是可重入锁的示例。以下是一个简单的示例,说明可重入锁的工作方式:

    1. import java.util.concurrent.locks.ReentrantLock;
    2. public class ReentrantLockExample {
    3. private static final ReentrantLock lock = new ReentrantLock();
    4. public static void main(String[] args) {
    5. lock.lock(); // 第一次获取锁
    6. try {
    7. System.out.println("First lock acquired.");
    8. lock.lock(); // 第二次获取锁,仍然允许
    9. try {
    10. System.out.println("Nested lock acquired.");
    11. } finally {
    12. lock.unlock(); // 释放第二次获取的锁
    13. System.out.println("Nested lock released.");
    14. }
    15. } finally {
    16. lock.unlock(); // 释放第一次获取的锁
    17. System.out.println("First lock released.");
    18. }
    19. }
    20. }

    3.自旋锁

    自旋锁是一种用于多线程同步的锁机制,它不会让线程进入阻塞状态,而是在尝试获取锁时,如果锁已经被其他线程占用,它会一直循环(自旋)等待锁被释放,而不放弃 CPU 时间片。自旋锁主要用于短时间内锁的竞争情况,希望竞争线程在等待期间能够快速释放锁,从而减少线程切换的开销。

    自旋锁的优点包括:

    1. 低开销: 自旋锁不涉及线程的上下文切换(Context Switching),因此在锁竞争不激烈的情况下,可以减少系统的开销。

    2. 等待时间短: 在短时间内,如果锁能够被释放,自旋锁可以快速获取锁,避免了进入阻塞状态的开销。

    3. 可预测性: 自旋锁的等待时间是可控的,不受操作系统调度器的影响,因此可以具有更可预测的性能。

    自旋锁的缺点包括:

    1. 高竞争情况下效率低: 在高度竞争锁的情况下,自旋锁会让线程忙等,浪费 CPU 时间,效率较低。

    2. 不适用于长时间等待: 自旋锁适用于短时间内锁的竞争,如果等待时间过长,会导致 CPU 时间浪费,不适合长时间等待锁的情况。

    在Java中,java.util.concurrent 包中提供了一种自旋锁的实现,称为 java.util.concurrent.atomic.AtomicReference,它可以用来构建简单的自旋锁。此外,Java中的 java.util.concurrent.locks 包中也提供了更复杂的锁实现,如 ReentrantLock,它可以通过参数来控制是否自旋等待。在使用自旋锁时,需要谨慎评估锁的使用场景,以确保它适合你的应用程序需求。

    4.死锁排查

    1.使用jps -l 定位进程号

    2.使用jstack pid 查看堆栈信息。

    可以看见,T1和T2互相持有了对方想要的锁。

    排查问题: 1.看日志,2. 查看堆栈信息。

  • 相关阅读:
    [附源码]JAVA毕业设计教学成果管理平台(系统+LW)
    【luogu P8326】Fliper(图论)(构造)(欧拉回路)
    Python邮件发送如何设置服务器?怎么配置?
    Java继承分析
    【验证用户输入的日期格式是否正确——工具类SimpleDateFormat类的使用】
    软件设计模式系列之二十一——观察者模式
    Linux xfs_growfs命令在 CentOS/RHEL 中扩展 XFS 文件系统
    Dynamsoft Barcode Reader SDK JAVA.9.2.X
    Python爬虫详解(一看就懂)
    爬取小说章节,并制作成词云进行宣传
  • 原文地址:https://blog.csdn.net/m0_62327332/article/details/133498610