Java 并发
2022/2/28 20:22:34
本文主要是介绍Java 并发,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
Java并发
基础问题
线程和进程的含义及区别
- 线程
- 进程
线程的状态和变迁
如何创建一个线程?
继承Thread类,实现Runable接口,Callable和Future
start() 和 run() 区别
如下图所示,start()方法只能调用一次,重复调用会抛出IllegalThreadStateException
,run()方法可以重复调用
为什么调用start()会执行run()方法
见下面JDK Thread start()中的注释;总结就是,start()方法会让JVM调用该Thread的run()方法,并在新线程上执行,即主线程调用start()过后返回并继续执行,新的线程将会被创建并用来执行run()方法
start()方法中call了一个native的start0()
native方法是指该方法的实现由非Java语言实现,如C或C++,不提供实现体,是个原生态方法
Java语言本身不能对操作系统底层进行访问和操作,但可以通过JNI(Java Native Interface)接口调用其他语言来实现对底层的访问,JNI是JDK的一部分
为什么不能直接调用run()方法
不会创建新的线程,该run()方法就是个普通的方法将在主线程执行,一般来说直接调用run()是一个bug或者失误
/** * Causes this thread to begin execution; the Java Virtual Machine * calls the <code>run</code> method of this thread. * <p> * The result is that two threads are running concurrently: the * current thread (which returns from the call to the * <code>start</code> method) and the other thread (which executes its * <code>run</code> method). * <p> * It is never legal to start a thread more than once. * In particular, a thread may not be restarted once it has completed * execution. * * @exception IllegalThreadStateException if the thread was already * started. * @see #run() * @see #stop() */ public synchronized void start() { /** * This method is not invoked for the main method thread or "system" * group threads created/set up by the VM. Any new functionality added * to this method in the future may have to also be added to the VM. * * A zero status value corresponds to state "NEW". */ if (threadStatus != 0) throw new IllegalThreadStateException(); /* Notify the group that this thread is about to be started * so that it can be added to the group's list of threads * and the group's unstarted count can be decremented. */ group.add(this); boolean started = false; try { start0(); started = true; } finally { try { if (!started) { group.threadStartFailed(this); } } catch (Throwable ignore) { /* do nothing. If start0 threw a Throwable then it will be passed up the call stack */ } } } private native void start0(); /** * If this thread was constructed using a separate * <code>Runnable</code> run object, then that * <code>Runnable</code> object's <code>run</code> method is called; * otherwise, this method does nothing and returns. * <p> * Subclasses of <code>Thread</code> should override this method. * * @see #start() * @see #stop() * @see #Thread(ThreadGroup, Runnable, String) */ @Override public void run() { if (target != null) { target.run(); } }
yield()方法有什么作用?
yield()方法只是让线程从RUNNING状态变为READY状态,但是也可能变为READY后又立刻被CPU调度器执行(变为RUNNING);如下图JDK注释说的,yield()只是告诉cpu调度器,我可以让出我的时间片,但是cpu调度器可以忽视这个消息;一般来说这个方法没有很多应该使用的场景,多是用于测试或者debug。
sleep()和yield()方法有什么区别?
sleep()让线程从RUNABLE状态变为TIMED_WAITING状态,让出CPU的时间片,不考虑线程优先级,yield()只会让优先级高的运行;sleep的线程不会丧失任何monitor的所有权
为什么sleep()和yield()方法是静态的?
sleep()和yield()都是谁调谁sleep/yield也都是自愿的,如果是实例方法会造成很多混乱,即我可以让你这个线程sleep
/** * A hint to the scheduler that the current thread is willing to yield * its current use of a processor. The scheduler is free to ignore this * hint. * * <p> Yield is a heuristic attempt to improve relative progression * between threads that would otherwise over-utilise a CPU. Its use * should be combined with detailed profiling and benchmarking to * ensure that it actually has the desired effect. * * <p> It is rarely appropriate to use this method. It may be useful * for debugging or testing purposes, where it may help to reproduce * bugs due to race conditions. It may also be useful when designing * concurrency control constructs such as the ones in the * {@link java.util.concurrent.locks} package. */ public static native void yield(); /** * Causes the currently executing thread to sleep (temporarily cease * execution) for the specified number of milliseconds, subject to * the precision and accuracy of system timers and schedulers. The thread * does not lose ownership of any monitors. * * @param millis * the length of time to sleep in milliseconds * * @throws IllegalArgumentException * if the value of {@code millis} is negative * * @throws InterruptedException * if any thread has interrupted the current thread. The * <i>interrupted status</i> of the current thread is * cleared when this exception is thrown. */ public static native void sleep(long millis) throws InterruptedException;
sleep()和wait()方法有什么区别?
wait()是Object的实例方法,有几个重点:
-
当前线程必须持有该object的锁(monitor)
-
wait()方法使得当前线程阻塞,并释放该object的锁(monitor)
-
当前线程将等待其他线程调用该object的notify()或者notifyAll()的方法重新获得锁并继续执行下去
-
响应中断
-
wait()方法必须用在循环里面
为什么wait()方法必须用在循环里面,即用while(condition not hold)而不是if(condition not hold):
当有多个线程由于相同condition not hold在wait()这行代码处阻塞等待持锁的时候,一旦condition change某个线程拿到锁了从wait()往下执行,又改变了condition,释放锁,由于另外个阻塞的线程是if判断,不再判断condition,拿到锁再处理时会发生错误。假设condition是array size > 0 而wait()之后会进行remove,那么就会出现IndexOutOfArray的问题
显而易见的相同点为:都使得当前线程阻塞了(TIMED_WAITING/WAITING状态)且都响应中断;不同点为:一个是Thread的静态方法,一个是Object的实例方法;wait()需要当前线程持有该object的monitor,sleep是当前线程自愿的行为;wait()调用后当前线程会释放monitor,sleep不会释放任何monitor;sleep自动唤醒,wait需要其他线程调用该object的notify和notifyAll()方法
/** * Causes the current thread to wait until another thread invokes the * {@link java.lang.Object#notify()} method or the * {@link java.lang.Object#notifyAll()} method for this object. * In other words, this method behaves exactly as if it simply * performs the call {@code wait(0)}. * <p> * The current thread must own this object's monitor. The thread * releases ownership of this monitor and waits until another thread * notifies threads waiting on this object's monitor to wake up * either through a call to the {@code notify} method or the * {@code notifyAll} method. The thread then waits until it can * re-obtain ownership of the monitor and resumes execution. * <p> * As in the one argument version, interrupts and spurious wakeups are * possible, and this method should always be used in a loop: * <pre> * synchronized (obj) { * while (<condition does not hold>) * obj.wait(); * ... // Perform action appropriate to condition * } * </pre> * This method should only be called by a thread that is the owner * of this object's monitor. See the {@code notify} method for a * description of the ways in which a thread can become the owner of * a monitor. * * @throws IllegalMonitorStateException if the current thread is not * the owner of the object's monitor. * @throws InterruptedException if any thread interrupted the * current thread before or while the current thread * was waiting for a notification. The <i>interrupted * status</i> of the current thread is cleared when * this exception is thrown. * @see java.lang.Object#notify() * @see java.lang.Object#notifyAll() */ public final void wait() throws InterruptedException { wait(0); }
join()方法怎么使用?
- join()方法不是一个静态方法,是Thread类的实例方法
- 当前线程会等待t.join()的t线程执行完成后才继续执行
- 响应中断
/** * Waits for this thread to die. * * <p> An invocation of this method behaves in exactly the same * way as the invocation * * <blockquote> * {@linkplain #join(long) join}{@code (0)} * </blockquote> * * @throws InterruptedException * if any thread has interrupted the current thread. The * <i>interrupted status</i> of the current thread is * cleared when this exception is thrown. */ public final void join() throws InterruptedException { join(0); }
守护线程和用户线程有什么区别?
- 主线程结束后用户线程还会继续运行,JVM存活
- 如果没有用户线程,都是守护线程,那么JVM结束(所有的线程都会结束)
守护线程是一种特殊的线程,在后台默默地完成一些系统性的服务,比如垃圾回收线程、JIT线程都是守护线程。与之对应的是用户线程,用户线程可以理解为是系统的工作线程,它会完成这个程序需要完成的业务操作。如果用户线程全部结束了,意味着程序需要完成的业务操作已经结束了,系统可以退出了。所以当系统只剩下守护进程的时候,java虚拟机会自动退出
什么是线程安全,有哪些线程安全程度?
点我
操作系统可以保证每个进程只能访问分配给自己的地址空间。而每个进程中都有一块内存空间(堆内存)是公共的,是所有线程都能访问的,就像小区的公园一样。操作系统也会为每个线程分配自己的内存空间(栈内存),只有自己这个线程能访问。
线程的安全其实是内存的安全。
如何保证线程安全?
- 放到栈内存,例如局部变量。但是也就只有自己能访问了,当变量从(方法内的)局部变量变为(类的)成员变量的时候,也就是从栈内存到公共的堆内存,就可能会出现问题
- 人人有份。ThreadLocal,每个Thread都有自己的一个map存储变量。这些变量虽然整体是放到堆内存的,但是由于自己拷贝一份到自己的map,自己处理自己的,就像是本地的一样,也就安全了。
- 只能看不能摸。例如,常量或者只读变量。
- 制定规则,先入为主。例如可重入锁。
- 地广人稀,CAS。
什么是中断
另外一个问题,我们应该
如何安全地结束一个线程?
stop等方法已经被舍弃了
- 使用退出标志,用一个while()循环,再设置一个boolean标志
- 使用中断
thread.interrupt()
将设置该线程中断位为true,包含两种,一种本质就是1,用while循环加上isInterrupted()来判断;一种通过catch InterruptedException来处理
interrupt()方法只是改变中断状态,不会中断一个正在运行的线程。需要用户自己去监视线程的状态为并做处理。支持线程中断的方法(也就是线程中断后会抛出interruptedException的方法)就是在监视线程的中断状态,一旦线程的中断状态被置为“中断状态”,就会抛出中断异常。这一方法实际完成的是,给受阻塞的线程发出一个中断信号,这样受阻线程检查到中断标识,就得以退出阻塞的状态。
更确切的说,如果线程被Object.wait, Thread.join和Thread.sleep三种方法之一阻塞,此时调用该线程的interrupt()方法,那么该线程将抛出一个 InterruptedException中断异常(该线程必须事先预备好处理此异常),从而提早地终结被阻塞状态。如果线程没有被阻塞,这时调用 interrupt()将不起作用,直到执行到wait(),sleep(),join()时,才马上会抛出 InterruptedException
/** * Interrupts this thread. * * <p> Unless the current thread is interrupting itself, which is * always permitted, the {@link #checkAccess() checkAccess} method * of this thread is invoked, which may cause a {@link * SecurityException} to be thrown. * * <p> If this thread is blocked in an invocation of the {@link * Object#wait() wait()}, {@link Object#wait(long) wait(long)}, or {@link * Object#wait(long, int) wait(long, int)} methods of the {@link Object} * class, or of the {@link #join()}, {@link #join(long)}, {@link * #join(long, int)}, {@link #sleep(long)}, or {@link #sleep(long, int)}, * methods of this class, then its interrupt status will be cleared and it * will receive an {@link InterruptedException}. * * <p> If this thread is blocked in an I/O operation upon an {@link * java.nio.channels.InterruptibleChannel InterruptibleChannel} * then the channel will be closed, the thread's interrupt * status will be set, and the thread will receive a {@link * java.nio.channels.ClosedByInterruptException}. * * <p> If this thread is blocked in a {@link java.nio.channels.Selector} * then the thread's interrupt status will be set and it will return * immediately from the selection operation, possibly with a non-zero * value, just as if the selector's {@link * java.nio.channels.Selector#wakeup wakeup} method were invoked. * * <p> If none of the previous conditions hold then this thread's interrupt * status will be set. </p> * * <p> Interrupting a thread that is not alive need not have any effect. * * @throws SecurityException * if the current thread cannot modify this thread * * @revised 6.0 * @spec JSR-51 */ public void interrupt() { if (this != Thread.currentThread()) checkAccess(); synchronized (blockerLock) { Interruptible b = blocker; if (b != null) { interrupt0(); // Just to set the interrupt flag b.interrupt(this); return; } } interrupt0(); }
/** * Tests whether the current thread has been interrupted. The * <i>interrupted status</i> of the thread is cleared by this method. In * other words, if this method were to be called twice in succession, the * second call would return false (unless the current thread were * interrupted again, after the first call had cleared its interrupted * status and before the second call had examined it). * * <p>A thread interruption ignored because a thread was not alive * at the time of the interrupt will be reflected by this method * returning false. * * @return <code>true</code> if the current thread has been interrupted; * <code>false</code> otherwise. * @see #isInterrupted() * @revised 6.0 */ public static boolean interrupted() { return currentThread().isInterrupted(true); } /** * Tests whether this thread has been interrupted. The <i>interrupted * status</i> of the thread is unaffected by this method. * * <p>A thread interruption ignored because a thread was not alive * at the time of the interrupt will be reflected by this method * returning false. * * @return <code>true</code> if this thread has been interrupted; * <code>false</code> otherwise. * @see #interrupted() * @revised 6.0 */ public boolean isInterrupted() { return isInterrupted(false); } /** * Tests if some Thread has been interrupted. The interrupted state * is reset or not based on the value of ClearInterrupted that is * passed. */ private native boolean isInterrupted(boolean ClearInterrupted);
interrupted和isInterrupted方法的区别?
如上图所示,interrupted()是线程的静态方法,返回当前线程的中断标记且会重置中断标记;isInterrupted()是实例方法,返回this的中断标记且不会重置中断标记。
可重入锁 & synchronized
可重入锁和synchronized的主要区别
- 机制是不一样的:synchronized是java内置的关键字,在JVM层面实现,系统会监控锁的释放,且自动释放,同步执行完或发生异常将释放锁;lock是JDK代码实现的,需要手动在finally模块释放,并可以非阻塞地获取锁。
- 性能不一样:竞争激烈的情况下lock会比synchronized好;竞争不激烈的情况下synchronized性能好,且synchronized会从偏向锁-->轻量级锁-->重量级锁升级
- 场景范围不一样:使用lock,线程2等待线程1释放锁的时候,可以不用一直等待,synchronized做不到
- 总的来说,lock提供了比synchronized更多的功能:如下图PPT所示
- synchronized 同步格式
synchronized(需要一个任意的对象(锁)){ 代码块中放操作共享数据的代码; }
- 关于Lock
- Lock其实只是一个接口
- ReentrantLock是唯一实现了Lock接口的类
可重入锁的实现?
先看JUC中的[AQS]
什么是synchronized的锁升级?
出自这里
Java对象在内存中的存储结构包括:请求头,实例数据,填充数据;对象头包含Mark Word(hashCode, GC分代年龄,锁信息),Class Metadata address(指向对象类型数据的指针),Array Length(该对象为数组时,数组的长度)
-
偏向锁
当对象被创建出来的时候,就有了偏向锁的标志位“01”,但状态为0,即被创建的对象的偏向锁不生效;但是,当线程执行到临界区(critical section)的时候,此时会利用CAS操作将线程ID插入到Markword中,并修改偏向锁的标志位,即状态为0,且能知道是哪个线程拥有了偏向锁。
当此线程执行之后,若这个同步块代码又被进入了,则会:
- 判断当前线程是否与Markword当中的线程id一致,若一致则继续执行
- 若不一致,检查对象是否还是可偏向的,即偏向锁的状态
- 如果未偏向,则利用CAS竞争锁,也就是第一次获取锁时的操作
- 如果已经偏向了,可能需要重新偏向,或者大部分时候升级为轻量级锁
偏向锁存在的意义:大部分时候都是同一个线程进入同一个同步块,那么这个时候就不需要再有加锁解锁的开销;偏向锁实际上是偏向第一个拿到锁的线程。
-
升级为轻量级锁
-
锁撤销-有开销
- 在一个安全的点停止拥有锁的线程
- 遍历线程栈,存在锁记录的话,需要修复锁记录和Markword使其变成无锁状态
- 唤醒当前线程,升级成轻量级锁
-
轻量级锁:锁标志位为“00”,此时Markword bitfield被替换为指向LockRecord的指针,之前的bitfield将会复制到创建的这个LockRecord里面,LockRecord有个owner的指针指向对象
-
轻量级锁又分为自旋锁和自适应自旋锁
-
自旋锁:
当有线程竞争锁时,会在原地循环等待,直到锁被释放可以立刻获取锁,而这个原地循环会消耗CPU。适用于同步代码块执行很快的场景。经验表明,大部分同步代码块都执行很快。设置一个原地循环的最大限制次数,如果超过就升级为重量级锁。默认为10次,可以通过-XX:PreBlockSpin来进行更改。
-
自适应自旋锁:
即可动态调整自旋等待的次数而不是个固定值。
-
轻量级锁也被称为非阻塞同步,乐观锁
-
-
升级为重量级锁
- 重量级锁以来对象内部monitor,monitor又依赖于操作系统的mutex锁,此时的标志位为“10”,bitfield将会指向mutex的指针
- 为什么重量级锁开销大?
- 此时,等待的线程会被阻塞,不会消耗CPU但是阻塞或者唤醒一个线程时都需要操作系统来帮忙,这就需要从用户态转换到内核态,这个转换很耗时。
- 重量级锁又被称为阻塞同步,悲观锁
为什么竞争激烈的情况下lock比synchronized性能好?
volatile关键字
volatile是什么意思?原理是什么?
CAS的特点是什么?
监视器(Monitor)和Condition区别
操作系统在面对 进程/线程 间的同步的时候所支持的同步原语中,信号量(semaphore)和互斥量(mutex)是最重要的。在使用mutex进行并发控制时,要非常小心地控制其down和up的操作,否则将引起死锁。在此基础出现了更高层次的同步原语,使我们不需要亲自去操作变量进行阻塞和唤醒,这个更高级的同步原语就是monitor---也是编程语言在语法上提供的语法糖,如何实现属于编译器的工作,不是操作系统的范畴。
-
monitor的基本元素
- 临界区:互斥进入临界区
- monitor对象和锁:monitor object有相应数据结构保存被阻塞的线程,和基于mutex的锁
- 条件变量和定义在monitor对象上的wait和notify操作
-
java中的每一个对象都有一个监视器(Monitor)
-
使用synchronized关键字来圈定临界区,实现互斥的界限
-
monitor object:
synchronized需要指定一个对象与之关联,如果synchronized修饰的是实例方法,那么其实关联的就是this,如果修饰的是类方法,关联的对象就是this.class,这个关联的对象就是monitor object。
锁:
Java对象存储在内存中,分别分为三个部分:对象头,实例数据和对齐填充,在对象头中就保存了锁标识。
-
wait和notify的操作:
这些方法的实现是JVM内部基于C++实现的一套机制,原理如下图:总结就是,waitThread(即调用object.wait()方法的线程)先要获取object的锁(也就是为什么需要配合synchronized关键字使用),如果获取成功,执行到object.wait()这行代码处,该线程就放进了这个object的waitQueue里面,表示在等待中。如果获取失败,该线程将被放进synchronizedQueue中,想要继续尝试获取锁。
当调用object.notifyAll()的方法时,所有等在waitQueue里的线程将移动到synchronizedQueue里面去和那些waitThread没有获取锁的线程一起尝试获取锁。
一旦某个线程获取成功了,就继续往下执行。
-
-
总结:Monitor是一套机制,它界于操作系统和我们实际编程中间,提供并发编程的方式。
J.U.C.
AQS
AQS是什么?
AQS叫做队列同步器(AbstractQueuedSynchronizer),是用来构建锁和同步组件的基础框架。
- AQS有一个内部类Node,每个Node主要就是存一个Thread,Thread的状态,父节点prev和子节点next
static final class Node { /** Marker to indicate a node is waiting in shared mode */ static final Node SHARED = new Node(); /** Marker to indicate a node is waiting in exclusive mode */ static final Node EXCLUSIVE = null; /** waitStatus value to indicate thread has cancelled */ static final int CANCELLED = 1; /** waitStatus value to indicate successor's thread needs unparking */ static final int SIGNAL = -1; /** waitStatus value to indicate thread is waiting on condition */ static final int CONDITION = -2; /** * waitStatus value to indicate the next acquireShared should * unconditionally propagate */ static final int PROPAGATE = -3; /** * Status field, taking on only the values: * SIGNAL: The successor of this node is (or will soon be) * blocked (via park), so the current node must * unpark its successor when it releases or * cancels. To avoid races, acquire methods must * first indicate they need a signal, * then retry the atomic acquire, and then, * on failure, block. * CANCELLED: This node is cancelled due to timeout or interrupt. * Nodes never leave this state. In particular, * a thread with cancelled node never again blocks. * CONDITION: This node is currently on a condition queue. * It will not be used as a sync queue node * until transferred, at which time the status * will be set to 0. (Use of this value here has * nothing to do with the other uses of the * field, but simplifies mechanics.) * PROPAGATE: A releaseShared should be propagated to other * nodes. This is set (for head node only) in * doReleaseShared to ensure propagation * continues, even if other operations have * since intervened. * 0: None of the above * * The values are arranged numerically to simplify use. * Non-negative values mean that a node doesn't need to * signal. So, most code doesn't need to check for particular * values, just for sign. * * The field is initialized to 0 for normal sync nodes, and * CONDITION for condition nodes. It is modified using CAS * (or when possible, unconditional volatile writes). */ volatile int waitStatus; /** * Link to predecessor node that current node/thread relies on * for checking waitStatus. Assigned during enqueuing, and nulled * out (for sake of GC) only upon dequeuing. Also, upon * cancellation of a predecessor, we short-circuit while * finding a non-cancelled one, which will always exist * because the head node is never cancelled: A node becomes * head only as a result of successful acquire. A * cancelled thread never succeeds in acquiring, and a thread only * cancels itself, not any other node. */ volatile Node prev; /** * Link to the successor node that the current node/thread * unparks upon release. Assigned during enqueuing, adjusted * when bypassing cancelled predecessors, and nulled out (for * sake of GC) when dequeued. The enq operation does not * assign next field of a predecessor until after attachment, * so seeing a null next field does not necessarily mean that * node is at end of queue. However, if a next field appears * to be null, we can scan prev's from the tail to * double-check. The next field of cancelled nodes is set to * point to the node itself instead of null, to make life * easier for isOnSyncQueue. */ volatile Node next; /** * The thread that enqueued this node. Initialized on * construction and nulled out after use. */ volatile Thread thread; /** * Link to next node waiting on condition, or the special * value SHARED. Because condition queues are accessed only * when holding in exclusive mode, we just need a simple * linked queue to hold nodes while they are waiting on * conditions. They are then transferred to the queue to * re-acquire. And because conditions can only be exclusive, * we save a field by using special value to indicate shared * mode. */ Node nextWaiter; /** * Returns true if node is waiting in shared mode. */ final boolean isShared() { return nextWaiter == SHARED; } /** * Returns previous node, or throws NullPointerException if null. * Use when predecessor cannot be null. The null check could * be elided, but is present to help the VM. * * @return the predecessor of this node */ final Node predecessor() throws NullPointerException { Node p = prev; if (p == null) throw new NullPointerException(); else return p; } Node() { // Used to establish initial head or SHARED marker } Node(Thread thread, Node mode) { // Used by addWaiter this.nextWaiter = mode; this.thread = thread; } Node(Thread thread, int waitStatus) { // Used by Condition this.waitStatus = waitStatus; this.thread = thread; } }
- AQS本身维护一个Node节点的双向链表队列,和一个表征同步状态的state字段
/** * Head of the wait queue, lazily initialized. Except for * initialization, it is modified only via method setHead. Note: * If head exists, its waitStatus is guaranteed not to be * CANCELLED. */ private transient volatile Node head; /** * Tail of the wait queue, lazily initialized. Modified only via * method enq to add new wait node. */ private transient volatile Node tail; /** * The synchronization state. */ private volatile int state;
AQS独占式和共享式的含义?
-
独占式:同一时刻只有一个线程持有同步状态,如ReentrantLock的实现
独占式获取同步状态过程:
- 共享式:共享资源可以被多个线程同时占有,如ReadWriteLock,CountdownLatch
AQS的模板模式是什么?
- AQS只是一个基础框架,
tryAcquire(int arg)
,tryRelease(int arg)
,tryAcquireShared(int arg)
,tryReleaseShared(int arg)
,isHeldExclusively()
都没有具体实现,因为公平锁有公平锁的实现方式,非公平锁有非公平锁的实现方式等,需要子类去实现
todo
简述并发工具Semaphore, CountdownLatch, CyclicBarrier的特点使用场景
线程安全的集合有哪些?
ConcurrentHashMap
BlockingQueue
CopyOnWriteArrayList
ConcurrentLinkedQueue
线程池
这篇关于Java 并发的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-23Springboot应用的多环境打包入门
- 2024-11-23Springboot应用的生产发布入门教程
- 2024-11-23Python编程入门指南
- 2024-11-23Java创业入门:从零开始的编程之旅
- 2024-11-23Java创业入门:新手必读的Java编程与创业指南
- 2024-11-23Java对接阿里云智能语音服务入门详解
- 2024-11-23Java对接阿里云智能语音服务入门教程
- 2024-11-23JAVA对接阿里云智能语音服务入门教程
- 2024-11-23Java副业入门:初学者的简单教程
- 2024-11-23JAVA副业入门:初学者的实战指南