[JUC concurrent programming] detailed explanation of locks and queues

πŸ“’πŸ“’πŸ“’πŸ“£πŸ“£πŸ“£

Hello! Hello, I'm [Bug terminator], a high-quality creator in CSDNJava field] πŸ†οΌŒ Alibaba cloud invited expert Blogger πŸ†οΌŒ 51CTO TOP celebrity πŸ† .

A highly motivated [Java domain blogger] with strong learning ability 😜😜😜

πŸ… The field of the [Bug terminator] blog is the learning of [back-end technology], and more [back-end technology] and [learning experience] will be continuously updated in the future. Occasionally, I will share some front-end basic knowledge, update actual projects, and develop applications for enterprises!
πŸ… If you have [xiaocute] who are interested in [back-end technology] and [front-end field], please pay attention to [Bug terminator] πŸ’žπŸ’žπŸ’ž


❀️❀️❀️ Thank you, big and small! ❀️❀️❀️

1, Definition of lock

☁️ Locking mechanism

The so-called lock can be understood as an integer in the memory. It has two states: idle state and locked state.

Through the lock mechanism, it can ensure that only one thread can enter the code of the critical area at a certain time point in the multi-core and multi-threaded environment, so as to ensure the consistency of the operation data in the critical area.

The so-called lock can be understood as an integer in the memory. It has two states: idle state and locked state. When adding a lock, judge whether the lock is idle. If it is idle, change it to the locked state and return success. If it is locked, it returns failed. When unlocking, change the lock status to idle status.

2, Lock and Synchronized

Traditional Synchronized

Add to code blocks or declared methods for thread safety

But it consumes performance

Lock lock

Locking and unlocking

Lock lock implementation class

Lock implements class fair lock and non fair lock

ReentrantLock
// The default is a non fair lock. When true is passed in during construction, it means a fair lock
    
//Ctrl + Alt + T place the cursor on the method to call up the shortcut try catch

The difference between Synchronized and Lock

  1. Both are unfair locks by default, which can improve performance
  2. Synchronized is a built-in Java keyword, and Lock is a Java class
  3. Synchronized cannot determine the status of acquiring a lock. Lock can determine whether a lock has been acquired
  4. Synchronized will automatically release the Lock, but the Lock will not be automatically released. The Lock needs to be manually closed. If it is not released, it will cause deadlock
  5. Synchronized thread 1 (lock acquisition, blocking), thread 2 (wait, wait...); Lock will not necessarily wait; Lock will try to acquire the lock tryLock
  6. Synchronized re-entry lock, non interruptible and unfair; Lock can re-enter the lock. You can judge the lock. You can set fair and unfair locks by yourself. Enter the boolean to select the lock
  7. Synchronized is suitable for locking a small number of code synchronization problems; Lock lock is suitable for locking a large number of synchronization codes

3, Problems caused by multithreading

⭐ Classic case: producer and consumer issues

Producer and consumer problem describes two thread processes, the so-called producer and consumer, which will cause atomicity (data inconsistency) problems during actual operation. The role of producers is to deliver data all the time, while the role of consumers is to consume data all the time. The key to this problem is to ensure that after the producer produces the product, if the consumer does not consume the product, the production will be stopped and the consumer will continue to produce the product after consumption. If the producer does not produce the product and the consumer has consumed the product, the consumer will wait for the producer to produce the product, and the consumer will consume the product after the producer completes production.

Producer and consumer issues of JUC version

Find Condition through Lock

Legacy and JUC versions

JUC realizes producers and consumers

package com.wanshi.productorcustomer;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * Communication between threads: producer and consumer issues! Wait for wakeup, notify wakeup
 * Threads execute alternately, and the same variable is operated by the product customer, num = 0
 * C: num+1
 * P: num-1
 * */
public class Productor2 {

    public static void main(String[] args) {
        Data2 data = new Data2();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "A").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "B").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "C").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "D").start();




    }
}


//Determine whether to wait, business and notification

//Digital resources
class Data2 {

    private int num = 0;

    Lock lock = new ReentrantLock();
    Condition condition = lock.newCondition();



    // +1 operation
    public void increment() throws InterruptedException {
        try {
            lock.lock();
            while (num != 0) {
                //wait for
                condition.await();
            }
            num ++;
            System.out.println(Thread.currentThread().getName() + " --- >" + num);
            //Notify other threads, +1 completed
            condition.signalAll();
        } catch (Exception e) {

        } finally {
            lock.unlock();
        }
    }

    //-1 operation
    public  void decrement() throws InterruptedException {
        try {
            lock.lock();
            while (num == 0) {
                //wait for
                condition.await();
            }
            num --;
            System.out.println(Thread.currentThread().getName() + " --- >" + num);
            //Notify other threads, -1 completed
            condition.signalAll();
        } catch (Exception e) {

        } finally {
            lock.unlock();
        }
    }

}

Any new technology will not only cover the original technology, but also have its own unique advantages and supplement the original technology

Condition

4, Read write lock

Definition of read / write lock

Read / write lock refers to two locks, read lock and write lock.

Why are there read-write locks?

  • Because the synchronized granularity is too large, it is not suitable for us. The granularity of reentrant locks is also larger than that of read locks (shared locks). We need locks with small granularity.
  • In most scenarios, the read does not need to be locked, but the write needs to be locked, because the write coverage and information inconsistency may occur if the write is not locked, and most reads require locks with smaller granularity, which will occupy less resources.

Exclusive lock (write lock) - can only be occupied by one thread at a time

Shared lock (read lock) multiple threads can occupy at the same time

ReadWriteLock read write lock

package com.wanshi.rw;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * Exclusive lock (write lock) can only be occupied by one thread at a time
 * Shared lock (read lock) multiple threads can occupy at the same time
 * Read write lock
 * ReadWriteLock
 * Read read coexistence
 * Read write cannot coexist
 * Write write cannot coexist
 */
public class ReadWriteLockDemo {

    public static void main(String[] args) {
        MyCache myCache = new MyCache();

        //write in
        for (int i = 1; i <= 5; i++) {
            final int type = i;
            new Thread(() -> {
                myCache.put(type+"", type);
            }, String.valueOf(i)).start();
        }
        for (int i = 1; i <= 5; i++) {
            final int type = i;
            new Thread(() -> {
                myCache.get(type + "");
            }, String.valueOf(i)).start();
        }
    }
}

/**
 * Custom cache
 */
class MyCache {
    private volatile Map<String, Object> map = new HashMap<>();

    //Read / write lock: more granular control
    private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();

    //Save, write, just want another thread to write
    public void put(String key, Object val) {
        readWriteLock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "write in" + key);
            map.put(key, val);
            System.out.println(Thread.currentThread().getName() + "write in OK");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            readWriteLock.writeLock().unlock();
        }
    }

    //Fetch, read, multiple threads can read
    public void get(String key) {
        readWriteLock.readLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "read");
            System.out.println(map.get(key));
            System.out.println(Thread.currentThread().getName() + "read OK");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            readWriteLock.readLock().unlock();
        }
    }
}

Operation effect

5, Common auxiliary classes (mandatory)

β˜€οΈCountDownLatch

package com.wanshi.add;

import java.util.concurrent.CountDownLatch;

//Counter
public class CountDownLatchDemo {

    public static void main(String[] args) throws InterruptedException {
        //The total number is 6. You can only use it when you have to perform a task!
        CountDownLatch countDownLatch = new CountDownLatch(6);

        for (int i = 1; i <= 6; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + " Go out");
                //Quantity -1
                countDownLatch.countDown();
            }, String.valueOf(i)).start();
        }

        //Wait for the counter to return to 0 before executing downward
        countDownLatch.await();

        System.out.println("Close Door");
    }
}

Principle:

countDownLatch.countDown(); Quantity -1

countDownLatch.await(); Wait for the counter to return to 0 before proceeding

Every time a thread calls countDown(), the count will be -1. Assuming that the counter becomes 0, the await method will be awakened and continue to execute

β›…CycliBarrier

package com.wanshi.add;

import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

public class CycliBarrierDemo {

    public static void main(String[] args) {
        // Collect 7 dragon balls to summon the divine dragon
        //Summon dragon ball thread
        CyclicBarrier cyclicBarrier = new CyclicBarrier(7, ()->{
            System.out.println("Summon DPCA successfully!");
        });
        for (int i = 1; i <= 7; i++) {
            final int temp = i;
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + "collect" + temp + "Dragon Balls");
                try {
                    //Wait for 7 threads to complete execution
                    cyclicBarrier.await();
                    System.out.println("abc");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

β›„Semaphore

Semaphore: semaphore

Grab a parking space!

6 cars – 3 parking spaces

package com.wanshi.add;

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class SemaphoreDemo {

    public static void main(String[] args) {
        //Current limiting
        Semaphore semaphore = new Semaphore(3);
        for (int i = 1; i <= 6; i++) {
            new Thread(() -> {
                //acquire() gets
                try {
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() + "Grab the parking space");
                    TimeUnit.SECONDS.sleep(2);
                    System.out.println(Thread.currentThread().getName() + "Leave the parking space");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    semaphore.release();
                }
                //release()
            }, String.valueOf(i)).start();
        }
    }
}

semaphore.acquire(); Gain. If it is full, wait until it is released!

semaphore.release(); Release will release the current semaphore by + 1, and then wake up the waiting thread!

Function: mutually exclusive use of multiple shared resources, concurrent flow restriction, and control the maximum number of threads

6, Other common locks

βŒ› Fair lock and unfair lock

Fair lock: it's very fair. You can't jump the queue. You must come first

Unfair locks: very unfair. You can jump the queue (all locks are unfair by default to ensure efficiency)

//Lock lock implementation class, default unfair lock
public ReentrantLock() {
    sync = new NonfairSync();
}

//Pass in true to change to fair lock
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

⚑ Reentrant lock

A reentrant lock is also called a recursive lock. It refers to a lock that can be called repeatedly and recursively. After the lock is used in the outer layer, the lock can still be used in the inner layer without deadlock. Such a lock is called a reentrant lock. To put it simply, it is always available when calling other synchronized modified methods or code blocks of this class within a synchronized modified method or code block.

ReentrantLock and synchronized in Java are both reentrant locks, which can avoid deadlocks to a certain extent.

Synchronized version reentrant lock

package com.wanshi.lock;


//Synchronized version
public class Demo01 {

    public static void main(String[] args) {
        Phone phone = new Phone();

        new Thread(() -> {
            phone.sms();
        }, "A").start();

        new Thread(() -> {
            phone.sms();
        }, "B").start();
    }
}

class Phone {

    public synchronized void sms() {
        System.out.println(Thread.currentThread().getName() + " ---> send message...");
        call();
    }

    public synchronized void call() {
        System.out.println(Thread.currentThread().getName() + " ---> phone....");
    }
}

Lock version reentrant lock

package com.wanshi.lock;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Demo02  {

    public static void main(String[] args) {
        Phone2 phone = new Phone2();

        new Thread(() -> {
            phone.sms();
        }, "A").start();

        new Thread(() -> {
            phone.sms();
        }, "B").start();
    }
}

class Phone2 {

    Lock lock = new ReentrantLock();

    //Note: Lock locks must occur in pairs. If they do not occur in pairs, deadlock may occur
    public void sms() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " ---> send message...");
            call();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public void call() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " ---> phone....");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

➿ Spin lock

spinLock: spinLock means that the thread trying to acquire the lock will not block immediately, but will try to acquire the lock in a circular manner. When the thread finds that the lock is occupied, it will continue to judge the lock status in a circular manner until it is acquired. This has the advantage of reducing the overhead of thread context switching, but the disadvantage is that the loop will consume CPU resources.

Spin lock

Self implementation of spin lock

package com.wanshi.lock;

import java.util.concurrent.atomic.AtomicReference;

public class SpinlockDemo {

    AtomicReference<Thread> atomicReference = new AtomicReference<>();

    //Lock
    public void myLock() {
        Thread thread = Thread.currentThread();

        System.out.println(Thread.currentThread().getName() + " ---> myLock");

        //It is expected that the updated conversion thread will exit directly after thread A enters, spin after thread B enters, and wait for thread A to finish spinning after thread B finishes spinning.
        while (!atomicReference.compareAndSet(null, thread)) {

        }

    }

    //Unlock
    public void myUnLock() {
        Thread thread = Thread.currentThread();
        System.out.println(Thread.currentThread().getName() + " ---> myUnLock");

        atomicReference.compareAndSet(thread, null);
    }
}

Test spin lock

package com.wanshi.lock;

import java.util.concurrent.TimeUnit;

public class Test {

    public static void main(String[] args) throws InterruptedException {
        SpinlockDemo lock = new SpinlockDemo();

        new Thread(() -> {
            lock.myLock();
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.myUnLock();
            }
        }, "A").start();

        TimeUnit.SECONDS.sleep(1);

        new Thread(() -> {
            lock.myLock();
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.myUnLock();
            }
        }, "B").start();
    }
}

βœ‚οΈ deadlock

Deadlock: two threads hold their own resources to try to obtain the resources of other threads, but the other threads are not released, resulting in blocking and deadlock

The causes of deadlock mainly include:

  • Insufficient system resources
  • There is a problem with the sequence of program execution
  • Improper allocation of resources, etc

How to eliminate deadlocks:

  • Mutually exclusive condition: a thread can only be called by one process at a time

  • Inalienable conditions: before the resources obtained by the process are used up, they will not be forcibly deprived by other processes, but can only be released by the process resources that have obtained the resources.

  • Request and hold condition: when a process is blocked due to a request for resources, it will hold on to the obtained resources

  • Circular waiting condition: a circular waiting resource relationship is formed between several processes

The above gives four necessary conditions leading to deadlock. As long as the system is locked, at least one of the above four conditions is true. In fact, the establishment of circular waiting implies the establishment of the first three conditions. It seems unnecessary to list these conditions. However, considering these conditions is beneficial to the prevention of deadlock, because you can prevent deadlock by destroying any of the four conditions.

Deadlock cases

A wants to take B, B wants to take a, and they are deadlocked

package com.wanshi.lock;

import java.util.concurrent.TimeUnit;

public class DeadLockDemo {

    public static void main(String[] args) {
        String lockA = "lockA";
        String lockB = "lockB";
        new Thread(new MyThread(lockA, lockB), "A").start();
        new Thread(new MyThread(lockB, lockA), "B").start();
    }
}

class MyThread implements Runnable {

    private String lockA;
    private String lockB;

    public MyThread(String lockA, String lockB) {
        this.lockA = lockA;
        this.lockB = lockB;
    }

    @Override
    public void run() {
        synchronized (lockA) {
            System.out.println(Thread.currentThread().getName() + "  lock: " + lockA + " --> get lock" + lockB);
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (lockB) {
                System.out.println(Thread.currentThread().getName() + "  lock: " + lockB + " --> get lock" + lockA);
            }
        }
    }
}

solve the problem

  1. Use jps -l to view the process number

  1. Using jstack process number to view stack information

Deadlock prevention ensures that the system will never enter a deadlock state

We can prevent deadlock by breaking the four necessary conditions for deadlock generation. Since mutual exclusion of resources is an inherent feature of resource use, it cannot be changed.

  • Breaking the "inalienable" condition: a process is in a waiting state when it cannot obtain all the resources it needs. During the waiting period, the resources it occupies will be implicitly released and re added to the system's resource list, which can be used by other processes. The waiting process can be restarted and executed only when it regains its original resources and newly applied resources.
  • Breaking "request and hold conditions": the first method is static allocation, that is, each process applies for all the resources it needs at the beginning of execution. The second method is dynamic allocation, that is, each process does not occupy system resources when it applies for the resources it needs.
  • Destroy the "circular waiting" condition: adopt the orderly allocation of resources. The basic idea is to number all resources in the system in sequence, and use the larger number for those in short supply and scarce. When applying for resources, the sequence of numbers must be followed. Only the process with a smaller number can apply for a process with a larger number

7, Blocking queue

β›½ Definition of blocking queue

Blocking idea: when the desired resource cannot be obtained, the thread waits for the resource, blocks, and wakes up when the resource is available

Blocking queue

Class structure

BlokingQueue is not new

Multithreaded concurrent processing, and the thread pool will use the BlokingQueue

Use queue

Blocking queue provided by JDK

The JDK provides seven types of blocking queues. As follows:

  • ArrayBlockingQueue: a bounded blocking queue consisting of an array structure.
  • LinkedBlockingQueue: a bounded blocking queue composed of a linked list structure.
  • PriorityBlockingQueue: an unbounded blocking queue that supports priority sorting
  • DelayQueue: an unbounded blocking queue implemented using priority queues
  • SynchronousQueue: a blocking queue that does not store elements
  • LinkedTransferQueue: an unbounded blocking queue composed of a linked list structure
  • LinkedBlockingQueue: a bidirectional blocking queue composed of a linked list structure

♨️ Common blocking queue

modeThrow exceptionWith return value, no exception is thrownBlocking waitWait while
add toaddoffer()putoffer(,)
removeremovepull()takepull(,)
Judge the first of the queueelementpeek--

ArrayBlockingQueue detailed usage:

package com.wanshi.bq;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.TimeUnit;

public class Test {

    public static void main(String[] args) throws InterruptedException {
        test2();
    }

    /**
     * Throw exception
     */
    public static void test1() {
        //Size of queue
        ArrayBlockingQueue arrayBlockingQueue = new ArrayBlockingQueue(3);
        System.out.println(arrayBlockingQueue.add("a"));
        System.out.println(arrayBlockingQueue.add("b"));
        System.out.println(arrayBlockingQueue.add("c"));

        //Legalstateexception: queue full throws an exception
//        System.out.println(arrayBlockingQueue.add("d"));

        System.out.println(arrayBlockingQueue.remove());

        //Get queue header
        System.out.println(arrayBlockingQueue.element());
        System.out.println(arrayBlockingQueue.remove());
        System.out.println(arrayBlockingQueue.remove());

//        System.out.println(arrayBlockingQueue.remove());
    }


    public static void test2() throws InterruptedException {
        ArrayBlockingQueue arrayBlockingQueue = new ArrayBlockingQueue(3);

        arrayBlockingQueue.offer("a");
        arrayBlockingQueue.offer("b");
        arrayBlockingQueue.offer("c");
        arrayBlockingQueue.offer("d");

        System.out.println(arrayBlockingQueue.poll());
        System.out.println(arrayBlockingQueue.poll());
        //Get queue header
        System.out.println(arrayBlockingQueue.peek());
        System.out.println(arrayBlockingQueue.poll());
        System.out.println(arrayBlockingQueue.poll());
    }

    public static void test3() throws InterruptedException {
        ArrayBlockingQueue arrayBlockingQueue = new ArrayBlockingQueue(3);

        arrayBlockingQueue.put("a");
        arrayBlockingQueue.put("b");
        arrayBlockingQueue.put("c");
//        arrayBlockingQueue.put("d");

        System.out.println(arrayBlockingQueue.take());
        System.out.println(arrayBlockingQueue.take());
        System.out.println(arrayBlockingQueue.take());
        System.out.println(arrayBlockingQueue.take());
    }

    public static void test4() throws InterruptedException {
        ArrayBlockingQueue arrayBlockingQueue = new ArrayBlockingQueue(3);

        arrayBlockingQueue.offer("a");
        arrayBlockingQueue.offer("b");
        arrayBlockingQueue.offer("c");
        arrayBlockingQueue.offer("d", 2, TimeUnit.SECONDS);

        System.out.println(arrayBlockingQueue.poll());
        System.out.println(arrayBlockingQueue.poll());
        System.out.println(arrayBlockingQueue.poll());
        System.out.println(arrayBlockingQueue.poll(2, TimeUnit.SECONDS));
    }
}

SynchronousQueue synchronize queue

package com.wanshi.bq;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;

public class SynchronousQueueDemo {

    public static void main(String[] args) {
        BlockingQueue<String> blockingDeque = new SynchronousQueue<>();

        new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + "put 1");
                blockingDeque.put("1");
                System.out.println(Thread.currentThread().getName() + "put 2");
                blockingDeque.put("2");
                System.out.println(Thread.currentThread().getName() + "put 3");
                blockingDeque.put("3");
            } catch (Exception e) {

            }
        },"T1").start();

        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(3);
                System.out.println(Thread.currentThread().getName() + "=>" + blockingDeque.take());
                TimeUnit.SECONDS.sleep(3);
                System.out.println(Thread.currentThread().getName() + "=>" + blockingDeque.take());
                TimeUnit.SECONDS.sleep(3);
                System.out.println(Thread.currentThread().getName() + "=>" + blockingDeque.take());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "T2").start();
    }
}

Operation effect

β›΅ Summary

The above is a brief overview of [Bug terminator] on [JUC concurrent programming] about locks and queues. The lock mechanism is very important. Queues are a good tool to improve our performance. The lock mechanism provides good support for our multithreading and solves the thread safety problem. The probability of asking about locks and queues in the interview is very high. Therefore, we should strengthen our study in this area and read them carefully, which is bound to win locks and queues!

If this [article] is helpful to you, I hope I can give a praise to [Bug terminator] πŸ‘οΌŒ It is not easy to create. If there are cute kids who are interested in [back-end technology] and [front-end field], they are also welcome to pay attention ❀️❀️❀️ [Bug terminator] ❀️❀️❀️, I will bring you great [harvest and surprise] πŸ’πŸ’πŸ’!

Tags: Java Interview IDEA

Posted by rgriffin3838 on Fri, 03 Jun 2022 03:38:41 +0530