IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> Java知识库 -> synchronized和volatile关键字实现和底层原理详解 -> 正文阅读

[Java知识库]synchronized和volatile关键字实现和底层原理详解

1. synchronized 关键字

1.1. 基本概念

(面试题:说一说自己对于 synchronized 关键字的了解)

synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

另外,在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

1.2. 使用 synchronized 关键字

(面试题:说说自己是怎么使用 synchronized 关键字,在项目中用到了吗)

synchronized关键字最主要的三种使用方式:

  • 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁
  • 修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

总结: synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能

下面我以一个常见的面试题为例讲解一下 synchronized 关键字的具体使用。

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

双重校验锁实现对象单例(线程安全)

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {
    }
    public static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

private volatile static Singleton uniqueInstance;

1.3. 对象锁和类锁

类锁就是全局锁,当多个线程调用不同对象实例的同步方法时会产生互斥,具体实现方式如下:

  • 修饰静态方法
  • 修饰代码块,synchronized中的锁对象是类
synchronized(Test.class){
    //哪个线程抢到了类锁,哪个线程就执行,这里面的内容就只由这个线程执行
}

对象锁就是实例锁,当多个线程调用同一个对象实例的同步方法时会产生互斥,具体实现方式如下:

  • 修饰普通方法
  • 修饰代码块,synchronized中的锁对象是普通对象实例
public class Test{
    Object lock = new Object();
    public void method(){
        synchronized(lock){
        }
    }
}

此时并没有达到线程互斥的目的,实际上并不是锁没有生效,问题的根源在于synchronized(lock)中锁对象lock的作用范围过小。不同的Test实例会有不同的lock锁对象,由于不满足“如果要达到多个线程互斥,那么多个线程必须竞争同一个对象锁”的条件,而没有形成竞争,所以不会实现互斥的效果。如果想要让上述程序达到同步的目的,那么我们可以对lock锁对象添加static关键字。

static Object lock = new Object();

例如在实战项目中,用到的一个插入数据之前先判断该用户是否含有这节课,通过课程名和用户ID来判断,此时多线程获取用户数据的场景下,由于无法保证整型变量n的原子性,有可能导致线程A将queryMapper的结果放入查询后返回的数量是0,此时if(n==1)判断成功,进入后线程B插入的是对象和queryMapper中判断重复的对象不是同一个,造成插入数据的重复。所以要确保有静态修饰的锁对象,使进入的线程只能有一个,执行结束后释放锁才让另一个线程执行。

synchronized (insertLock) {
    int n = gradeMapper.selectCount(queryWrapper);
    if (n == 0) {
        int insert = gradeMapper.insert(gradeObj);
        if (insert == 1) {
            System.out.println(userOpenid + "---" + "插入成绩成功---" + subject);
        }else {
            log.info(userOpenid +  "未能成功插入成绩");
        }
    }
}

1.4. synchronized 关键字的底层原理

首先演示一下对synchronized同步锁的实现猜想
在这里插入图片描述

如果synchronized同步锁想要实现多线程访问的互斥性,就必须保证多个线程竞争同一个资源。所以要实现锁互斥就必须满足以下两个条件:

  • 必须竞争同一个共享资源。
  • 需要有一个标记来识别当前锁的状态是空闲还是繁忙。

第一个条件通过lock锁对象来实现即可;第二个条件需要有一个地方来存储抢占锁的标记,否则当其他线程来抢占资源时,不知道当前是应该正常执行还是应该排队。实际上,这个锁标记是存储在对象头中的。

在这里插入图片描述

在堆内存中,Java对象存储结构可分为三个部分:对象头、实例数据、对齐填充。

Java中对象头有三个部分组成:Mark World、Klass Pointer、Length。

Mark World记录了与对象和锁相关的信息,当这个对象作为锁对象来实现synchronized的同步操作时,锁标记和相关信息都是存储在Mark World中的。

在这里插入图片描述

在Mark World中,锁的类型有偏向锁、轻量级锁、重量级锁,其实,早在jdk1.6之前,synchronized只提供了重量级锁的机制,重量级锁的本质就是我们前面对于锁的认知,也就是没有获得锁的线程会通过park方法阻塞,接着被获得锁的线程唤醒后再次抢占锁,直到抢占成功。

重量级锁依赖于底层操作系统的Mutex Lock来实现,而使用Mutex Lock需要把当前线程挂起,并**从用户态切换到内核态(从程序层面切换到CPU层面)**来执行,这种切换带来的性能开销是非常大的。因此如何在性能和线程安全性之间做好平衡,就是一个值得深讨的话题了。

在jdk1.6之后,synchronized做了很多优化,其中针对锁的类型增加了偏向锁和轻量级锁,这两种锁的核心设计理念就是如何让线程在不阻塞的情况下达到线程安全的目的。

synchronized 关键字底层原理属于 JVM 层面。

① synchronized 同步语句块的情况

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class

在这里插入图片描述

从上面我们可以看出:

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因)的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

② synchronized 修饰方法的的情况

public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized 方法");
    }
}

在这里插入图片描述

synchronized关键字原理

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

1.5. synchronized的锁类型

JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。(面试题:说说 JDK1.6 之后的synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗)

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

1.5.1. 偏向锁

偏向锁的作用是,线程在没有线程竞争的情况下去访问synchronized同步代码块时,会尝试先通过偏向锁来抢占访问资格,这个抢占过程是基于CAS来完成的,如果抢占锁成功,则直接修改对象头中的锁标记。这个锁标记就方便当该线程再次访问这个同步方法的时候只需进行判断后决定是否进入执行即可。其实现原理如下:

在这里插入图片描述

此时你可能会疑惑:既然是线程在没有线程竞争的情况下去访问synchronized同步代码块,既然没有线程竞争,那为什么还需要设置锁呢,实际上对程序开发来说,加锁是为了防范线程安全性的风险,但是是否有线程竞争并不由我们来控制,而是由应用场景来决定的,同时,这也是synchronized锁升级机制的一个基本的初始部分。

1.5.2. 轻量级锁

在线程没有竞争时,使用偏向锁能够在不影响性能的前提下获得锁资源,但是同一时刻只允许一个线程获得锁资源,如果突然有多个线程来访问同步方法,那么没有抢占到锁资源的线程要怎么办呢?很显然偏向锁解决不了这个问题。

正常情况下,没有抢占到锁的线程肯定要阻塞等待被唤醒,也就是说按照重量级锁的逻辑来实现,但是在此之前,有没有更好的平衡方案呢?于是就有了轻量级锁的设计。

所谓的轻量级锁,就是没有抢占到锁的线程,进行一定次数的重试(自旋)。比如线程第1次没抢到锁则重试几次,如果在重试的过程中抢占到了锁,那么这个线程就不需要阻塞,这种实现方式我们称为自旋锁,具体的实现流程如下:

在这里插入图片描述

当然,线程通过重试来抢占锁的方式是有代价的,因为线程如果不断自旋重试,那么CPU 会一直处于运行状态。如果持有锁的线程占有锁的时间比较短,那么自旋等待的实现带来性能的提升会比较明显。反之,如果持有锁的线程占用锁资源的时间比较长,那么自旋的线程就会浪费 CPU 资源,所以线程重试抢占锁的次数必须要有一个限制

在JDK 1.6中默认的自旋次数是10次,我们可以通过-XX:PreBlockSpin 参数来调整自旋次数。同时开发者在JDK 1.6 中还对自旋锁做了优化,引入了自适应自旋锁,自适应自旋锁的自旋次数不是固定的,而是根据前一次在同一个锁上的自旋次数及锁持有者的状态来决定的。如果在同一个锁对象上,通过自旋等待成功获得过锁,并且持有锁的线程正在运行中,那么JVM 会认为此次自旋也有很大的机会获得锁,因此会将这个线程的自旋时间相对延长。反之,如果在一个锁对象中,通过自旋锁获得锁很少成功,那么JVM会缩短自旋次数。

1.5.3. 重量级锁

如果没有强占到锁资源的县城通过一定次数的自旋后,发现仍然没有获得锁,就只能阻塞等待了,所以最会升级到重量级锁,通过系统层面的互斥量来抢占锁的资源。重量级锁的实现原理如下:

在这里插入图片描述

在整体来看,如果在偏向锁、轻量级锁这些类型中无法让线程获得锁资源,那么这些没获得锁的线程的最终的结果仍然是阻塞等待,直到获得锁的线程释放锁之后才能被唤醒。而在整个优化过程中,我们通过乐观锁的机制来保证线程的安全性。

synchronized同步锁最终的底层加锁机制是JVM层面根据线程的竞争情况逐步升级来实现的,从而达到同步锁性能和安全性平衡的目的,而这个过程并不需要开发者干预。

1.6. CAS机制的实现原理分析

在synchronized中很多地方都用到了CAS机制,它的叫法有很多,比如CompareAndSwap等,它是一个能够进行比较和替换的方法,这个方法能够在多线程环境下保证对一个共享变量进行修改时的原子性不变。CAS的工作原理如下:

在这里插入图片描述

Java中的AtomicInteger常用于多线程执行的场景中,例如当多个线程操作海量用户数据的时候,利用AtomicInteger userCount = new AtomicInteger();在execute中即可实现对用户数量的计数,而不产生线程安全问题。这个过程正是利用了CAS机制来保证其原子性。

1.7. 锁升级的实现流程

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1dZ8rkVb-1641135128659)(C:\Users\CTC\Desktop\synchronized+volatile.assets\image-20220102215447182.png)]

至于锁升级的过程中,偏向锁、轻量级锁、重量级锁的实现原理,包含其获取和释放过程是一个比较复杂的流程,见《Java并发编程——深度解析与实战》P77。

不管是线程级别的死锁,还是数据库级别的死锁,只能通过人工干预去解快,所以抵1发仕写程序的时候提前预防死锁的问题。导致死锁的条件有四个,这四个条件同时满足就会产生死锁。互斥条件,共享资源又和¥只能被一个线程占用。请求和保持条件,线程T1已经取得共享资源叉,在等待共享资源Y的时候,不释放共享
资源X。不可抢占条件,其他线程不能强行抢占线程 T1 古有的资源。循环等待条件,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,即循环等待。

1.8. synchronized使用不当带来的死锁问题

简单来说就是两个或者两个以上的线程在执行的过程中,由于争夺同一个共享资源造成的相互等待的现象,在没有外部干预的情况下,这些线程将会无法往下执行,这些一直处于相互等待资源的线程就被称为死锁线程。

不管是线程级别的死锁,还是数据库级别的死锁,只能通过人工干预去解快,所以我们要在写程序的时候提前预防死锁的问题。导致死锁的条件有四个,这四个条件同时满足就会产生死锁

  • 互斥条件,共享资源X 和 Y只能被一个线程占用。
  • 请求和保持条件,线程T1已经取得共享资源叉,在等待共享资源Y的时候,不释放共享资源X。
  • 不可抢占条件,其他线程不能强行抢占线程 T1 占有的资源。
  • 循环等待条件,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,即循环等待。

如何解决

按照前面说的四个死锁的发生条件,我们只需要破坏其中任意一个,就可以避免死锁的产生。其中,互斥条件我们不可以破坏,因为这是互斥锁的基本约束,其他三个条件都可以破坏。

  • 对于请求和保持条件,我们可以一次性申请所有的资源,这样就不存在等待了。
  • 对于不可抢占条件,当占用部分资源的线程进一步申请其他资源时,如果申请不到,则可以主动释放其占有的资源,这样不可抢占条件就被破坏掉了。
  • 对于循环等待条件,可以通过按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

具体代码示例见《Java并发编程——深度解析与实战》P92。

1.9. synchronized和ReentrantLock 的区别

① 两者都是可重入锁

两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

② synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

③ ReentrantLock 比 synchronized 增加了一些高级功能

相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)

  • ReentrantLock提供了一种能够 中断等待锁的线程 的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
  • synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition() 方法。Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能,也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。

如果你想使用上述功能,那么选择ReentrantLock是一个不错的选择。

④ 性能已不是选择标准

2. volatile关键字

2.1. Java内存模型

在 JDK1.2 之前,Java的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致

要解决这个问题,就需要把变量声明为volatile,这就指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。

例如在单例模式的实现中,使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {
    }
    public static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

什么是可见性?

如果一个线程对一个共享变量进行了修改,而其他线程不能及时地读取修改之后的值,那么我们认为在多线程环境下该共享变量存在可见性问题,举个具体例子如下:

public class VolatileExample{
    //public static boolean stop = false;
    public volatile static boolean stop = false;  //1秒后线程能正常结束
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            int i = 0;
            while(!stop){
                i++;
            }
        });
        t1.start();
        System.out.printin("begin start thread");
        Thread.sleep(1000) ;
        stop = true;
    }
}

代码的逻辑很简单,首先t1通过stop变量来判断是否一直执行while中的内容,而主线程在sleep一秒钟后就将stop设置为了true,但此时t1线程却不会停止执行,这是因为线程 t1 可以把变量保存在本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程(例如本代码的主线程)在主存中修改了一个变量(stop)的值,而另外一个线程(线程t1)还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。所以此处就要添加volatile关键字,去指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。
在这里插入图片描述

由此可见,volatile可以禁止编译器的优化,在多处理器环境下保证共享变量的可见性。

2.2. synchronized 关键字和 volatile 关键字的区别

synchronized关键字和volatile关键字比较

  • volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些。
  • 多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞
  • volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访问资源的同步性
  Java知识库 最新文章
计算距离春节还有多长时间
系统开发系列 之WebService(spring框架+ma
springBoot+Cache(自定义有效时间配置)
SpringBoot整合mybatis实现增删改查、分页查
spring教程
SpringBoot+Vue实现美食交流网站的设计与实
虚拟机内存结构以及虚拟机中销毁和新建对象
SpringMVC---原理
小李同学: Java如何按多个字段分组
打印票据--java
上一篇文章      下一篇文章      查看所有文章
加:2022-01-03 15:56:35  更:2022-01-03 15:57:10 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/24 8:58:38-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码