Статьи

Сопутствие Java: краткий обзор

Java обычно компилируется в язык байт-кода, который позже интерпретируется JVM (виртуальной машиной Java). Этот подход имеет свои преимущества и недостатки. С одной стороны, разработчики приложений могут «писать один раз, работать где угодно», что является одним из самых замечательных преимуществ, но с другой стороны, этот подход также делает Java языком программирования с низкой производительностью.

Java подверглась критике за ее производительность по сравнению с другими языками, и в результате был использован весь набор методов производительности.

Байт-код Java может быть либо интерпретирован во время выполнения виртуальной машиной, либо он может быть скомпилирован во время загрузки или во время выполнения в машинный код, который выполняется непосредственно на оборудовании компьютера. Интерпретация медленнее, чем собственное выполнение, и компиляция во время загрузки или во время выполнения имеет начальное снижение производительности для компиляции. Хотя эта концепция может объяснить большую часть проблем с производительностью, мы можем кое-что сделать для повышения производительности наших программ.

Параллельное программирование всегда было трудным, и это является одним из самых важных недостатков в знаниях программистов с тех пор. Тем не менее, команда Java создала пакеты для управления этим безболезненным способом. Тем не менее, необходимо знать основы, чтобы использовать это в приложении и получить желаемые результаты.

Компьютерная система обычно имеет много активных процессов и потоков. Это верно даже в системах, которые имеют только одно ядро ​​выполнения и, следовательно, имеют только один поток, фактически выполняющийся в данный момент. Время обработки для одного ядра распределяется между процессами и потоками с помощью функции ОС, называемой квантованием времени.

Процесс имеет автономную среду исполнения. Процесс обычно имеет полный, частный набор основных ресурсов времени выполнения; в частности, каждый процесс имеет свое собственное пространство памяти. Потоки иногда называют легкими процессами. И процессы, и потоки обеспечивают среду выполнения, но для создания нового потока требуется меньше ресурсов, чем для создания нового процесса. Потоки живут внутри процесса, и у процесса есть хотя бы один поток.

Для того чтобы ответить на некоторые вопросы, я приложу все усилия (:)) и представлю сценарий, который придаст смысл всем этим. Предположим, вы хотите смоделировать ситуацию, когда у вас есть несколько потоков, запрашивающих некоторую информацию, и вы хотите максимизировать свои ресурсы, имея несколько потоков, обрабатывающих этот запрос. Первое, что приходит мне в голову, это сценарий «производитель-потребитель».

Следующий большой вопрос для меня заключался в том, какой из них будет тема этого короткого приложения, которое позволит мне высказать свою точку зрения. И я подумал в самой распространенной ситуации. Потоки пытаются внести депозит или снять деньги только с одного аккаунта. Таким образом, вы получаете многопоточность, контролируете количество потоков, которые потребляют ресурсы, и обмениваетесь данными, что может привести к повреждению данных.

Давайте посмотрим код:

Прежде всего я реализую класс Accout:

public class Account {
    private static ReentrantLock lock = new ReentrantLock(true);
    
    private long balance = 200;
    
    public Account() {
    }
    
    public long transfer(long amount) throws CorruptedAccountException {
//        lock.lock();
//        try {
            if (balance < 0)
                throw new CorruptedAccountException();
        
            if ((balance + amount) < 0)
                return balance;
            
            // This emulate a complex task after the control and before the
            // operation that affect the shared variable.
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            
            balance += amount;
            
            return balance;
        
//        } finally {
//            lock.unlock();
//        }
    }
}

Класс Account создается только один раз, а затем передается по значению всем другим классам, как вы увидите далее. Это даст нам идеальный сценарий для обмена данными, а также является критически важной частью кода. Обратите внимание на метод перевода, где все происходит. Там есть элемент управления, который выдает исключение, если баланс равен 0 или меньше. Таким образом, мы узнаем, обращаются ли два потока к одной и той же переменной и оставляют ли там противоречивые данные Теперь у нас есть производитель, который в этом случае делится на два класса. DepositRequestsProducer, который отвечает за добавление денег на счет, и WithdrawRequestsProducer, который отвечает за снятие средств со счета. Оба почти идентичны, за исключением знака суммы (это было сделано так, чтобы прояснить идею). Более того,важно отметить, что оба производителя добавляют транзакции в очередь. Это не мелочь, на самом деле эта очередь является ключом к предотвращению роста объема транзакций, бесконечно требующих все больше и больше ресурсов до тех пор, пока «не хватит памяти», чтобы остановить программу. Это произошло бы, если бы производители добавляли транзакции быстрее, чтобы их мог обработать потребитель.

public class DepositRequestsProducer implements Runnable {
    
    private Thread tread;
    private BlockingQueue<long> queue;

    public DepositRequestsProducer(BlockingQueue<long> q) {
        queue = q;
        tread = new Thread(this);
        tread.start();
    }

    public void run() {
        try {
            Random randomGenerator = new Random();
            while (true) {
                int amount = randomGenerator.nextInt(100);            
                queue.put(new Long(amount));
                Thread.yield();
            }
            
        } catch (InterruptedException ex) {
            ex.printStackTrace();
        }
    }
}
public class WithdrawRequestsProducer implements Runnable {

    private Thread thread;
    private BlockingQueue<long> queue;

    public WithdrawRequestsProducer(BlockingQueue<long> q) {
        queue = q;
        thread = new Thread(this);
        thread.start();
    }

    public void run() {
        try {
            Random randomGenerator = new Random();
            while (true) {
                int amount = randomGenerator.nextInt(300);
                queue.put(new Long(amount*-1));
                Thread.yield();
            }
            
        } catch (InterruptedException ex) {
            ex.printStackTrace();
        }
    }
}

Обратите внимание, что оба класса получают очередь. В эту очередь будут помещены произведенные ими транзакции. В последнее время потребитель получит оттуда транзакции для их обработки. С другой стороны нам нужен Потребитель, который отвечает за обработку транзакций:

public class TransactionConsumer implements Runnable {
    
    private Thread tread;
    private BlockingQueue<long> queue;
    private Account account;

    public TransactionConsumer(BlockingQueue<long> q, Account account, int thread) {
        queue = q;
        this.account = account;
        tread = new Thread(this, "Consumer_" + thread);
        tread.start();
    }

    public void run() {
        long request, result = 0;
        Runtime s_runtime = Runtime.getRuntime();
        
        NumberFormat numberFormat = NumberFormat.getNumberInstance();
        numberFormat.setRoundingMode(RoundingMode.DOWN);

        try {
            while (true) {
                request = queue.take().longValue();
                result = account.transfer(request);
                
                double freeMemory = (s_runtime.freeMemory() / 1048576);
                
                System.out.println("Calculated result after add " + request + " is " + result + " -- Free Memory: " +  numberFormat.format(freeMemory) + " / " + numberFormat.format(s_runtime.totalMemory() / 1048576));
            }
            
        } catch (InterruptedException ex) {
            ex.printStackTrace();
        }
        
        System.exit(0);
    }
}

Этот класс также получает очередь в конструкторе и открывает поток для запуска, как только транзакция была найдена в очереди. Наконец, у нас есть класс, который заставляет его работать:

import java.util.concurrent.ArrayBlockingQueue;

public class TestConcurrency {

    public static void main(String[] args) {
        Account account = new Account();
        final ArrayBlockingQueue<long> queue = new ArrayBlockingQueue<long>(40);
        
        for (int i = 0; i < 15; i++)
            new TransactionConsumer(queue, account, i);

     for (int i = 0; i < 30; i++) {
         new Thread(new DepositRequestsProducer(queue)).start();
         new Thread(new WithdrawRequestsProducer(queue)).start();
     }
    }
}

As you can see, when you run the program, the first thread executes the main method. Here an ArrayBlockingQueue is created as final and this is the first clue to analyse. This queue, as we mentioned above, protects the program of an infinite increase on the resource demand. Once the queue reaches the highest of it’s capacity, all the threads that intend to put transactions in, will be put to sleep until a consumer takes a transaction out. At this moment all the threads that were sleeping waiting for a place in the queue, are wake up to compete for the queue. The implementation of this ArrayBlockingQueue is very useful because it avoids us a lot of work regarding monitoring threads.

Then we can see that a specified number of threads are created to run producers and consumers, and we are going to have at least 75 threads running as sub-processes of the main one.

Back in the Account class, all the threads will access transfer method code without any synchronisation. This will produce shared data corruption when two threads access to critical sections of code. Lets present a simple example:

Suppose that two threads(which we’ll call A and B) access transfer method. Let say that balance variable is 125 and and the thread A is invoked with -100 and the thread B with -50. The thread A passes through the «if» that prevent negative balance and reaches the sleep command. Meanwhile, thread B passes through the balance check before the thread A updates the balance amount. Consequently, the balance will be updated by thread A leaving the balance in 25. When thread B reaches the balance update operation, the shared data gets corrupted.

This is the moment in which we realise that this is «a critical code». Here is when we have to add some protection to transfer method. In Account class you will see some commented lines of code which you should un-comment in order to see the difference.

I have added some extra lines in order to add some information related to memory use. Try to run the program with and without comments to see the difference.

I hope this make sense to you and the code works appropriately.